crewforge 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (80) hide show
  1. {crewforge-0.2.2/src/crewforge.egg-info → crewforge-0.3.0}/PKG-INFO +30 -2
  2. {crewforge-0.2.2 → crewforge-0.3.0}/README.md +29 -1
  3. {crewforge-0.2.2 → crewforge-0.3.0}/pyproject.toml +1 -1
  4. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/__init__.py +1 -1
  5. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/review/cli.py +21 -6
  6. crewforge-0.3.0/src/crewforge/recipes/review/report.py +191 -0
  7. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/review/sources.py +33 -3
  8. {crewforge-0.2.2 → crewforge-0.3.0/src/crewforge.egg-info}/PKG-INFO +30 -2
  9. crewforge-0.2.2/src/crewforge/recipes/review/report.py +0 -103
  10. {crewforge-0.2.2 → crewforge-0.3.0}/LICENSE +0 -0
  11. {crewforge-0.2.2 → crewforge-0.3.0}/setup.cfg +0 -0
  12. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/__main__.py +0 -0
  13. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/cli.py +0 -0
  14. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/cli_support.py +0 -0
  15. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/__init__.py +0 -0
  16. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/agent.py +0 -0
  17. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/client.py +0 -0
  18. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/config.py +0 -0
  19. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/crew.py +0 -0
  20. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/errors.py +0 -0
  21. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/parsing.py +0 -0
  22. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/providers.py +0 -0
  23. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/core/usage.py +0 -0
  24. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/py.typed +0 -0
  25. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/__init__.py +0 -0
  26. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/__init__.py +0 -0
  27. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/agents.py +0 -0
  28. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/citations.py +0 -0
  29. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/cli.py +0 -0
  30. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/disclosure.py +0 -0
  31. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/language.py +0 -0
  32. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/locales/en.yaml +0 -0
  33. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/locales/ms.yaml +0 -0
  34. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/pipeline.py +0 -0
  35. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/report.py +0 -0
  36. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/academic/settings.py +0 -0
  37. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/__init__.py +0 -0
  38. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/agents.py +0 -0
  39. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/analytics.py +0 -0
  40. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/appcheck.py +0 -0
  41. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/cli.py +0 -0
  42. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/formatters.py +0 -0
  43. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/generator.py +0 -0
  44. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/pipeline.py +0 -0
  45. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/scaffold.py +0 -0
  46. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/docker-compose.j2 +0 -0
  47. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/dockerfile.j2 +0 -0
  48. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/env.example.j2 +0 -0
  49. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/fastapi_main.j2 +0 -0
  50. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/model.j2 +0 -0
  51. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/requirements.j2 +0 -0
  52. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/route.j2 +0 -0
  53. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/templates/test.j2 +0 -0
  54. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/api/utils.py +0 -0
  55. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/content/__init__.py +0 -0
  56. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/content/agents.py +0 -0
  57. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/content/cli.py +0 -0
  58. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/content/export.py +0 -0
  59. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/content/pipeline.py +0 -0
  60. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/content/settings.py +0 -0
  61. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/content/text.py +0 -0
  62. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/__init__.py +0 -0
  63. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/agents.py +0 -0
  64. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/cli.py +0 -0
  65. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/export.py +0 -0
  66. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/index.py +0 -0
  67. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/ingest.py +0 -0
  68. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/pipeline.py +0 -0
  69. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/retrieve.py +0 -0
  70. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/docs/settings.py +0 -0
  71. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/review/__init__.py +0 -0
  72. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/review/agents.py +0 -0
  73. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge/recipes/review/pipeline.py +0 -0
  74. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge.egg-info/SOURCES.txt +0 -0
  75. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge.egg-info/dependency_links.txt +0 -0
  76. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge.egg-info/entry_points.txt +0 -0
  77. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge.egg-info/requires.txt +0 -0
  78. {crewforge-0.2.2 → crewforge-0.3.0}/src/crewforge.egg-info/top_level.txt +0 -0
  79. {crewforge-0.2.2 → crewforge-0.3.0}/tests/test_honest_failure_e2e.py +0 -0
  80. {crewforge-0.2.2 → crewforge-0.3.0}/tests/test_readme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crewforge
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: One crew of LLM agents, five recipes: code review, document Q&A, API scaffolding, content and honest academic-writing help — on any OpenAI-compatible provider.
5
5
  Author: Muhammad Aiman
6
6
  License-Expression: MIT
@@ -145,7 +145,7 @@ tables are trimmed to their totals.
145
145
 
146
146
  | Command | Agents | What you get |
147
147
  |---|---|---|
148
- | `review PATH` | security, style, complexity, logic, testing, documentation (parallel) → summary | Findings by severity, a decision and a score; `-f json` for CI |
148
+ | `review PATH` | security, style, complexity, logic, testing, documentation (parallel) → summary | Findings by severity, a decision and a score; `-f json` for CI, `-f sarif` for GitHub code scanning |
149
149
  | `ingest FILES` | ingester, indexer (no LLM) | How each file was parsed and chunked |
150
150
  | `ask FILES -q Q [--verify] [--rerank]` | retriever → answerer → fact-checker | An answer with `[Source N]` citations and a claim-by-claim verdict |
151
151
  | `summarize FILES` | summarizer | Summary and key points per document |
@@ -177,6 +177,30 @@ $ crewforge review app.py -f markdown -o review.md # app.py builds SQL with
177
177
  total 7 calls 22,366 tokens (provider-reported)
178
178
  ```
179
179
 
180
+ `-f sarif` writes a [Static Analysis Results Interchange Format 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html)
181
+ log (`sarif`), the format GitHub code scanning reads, so findings can appear in a repository's Security tab. Each finding becomes one
182
+ result: `critical` and `error` map to level `error`, `warning` to `warning`, `info` to `note`; the rule is the
183
+ finding's CWE when it has one, otherwise the reviewer's name. When you review a directory, the line a reviewer
184
+ reports is mapped back to the file it came from. A finding whose line is missing or outside the code points at the
185
+ reviewed path without a line. Reviewers that fail are listed in `invocations`, and the exit code is the same as for
186
+ the other formats.
187
+
188
+ ```yaml
189
+ # .github/workflows/review.yml (needs an API key secret, e.g. OPENAI_API_KEY)
190
+ permissions:
191
+ contents: read
192
+ security-events: write
193
+ steps:
194
+ - uses: actions/checkout@v4
195
+ - run: pipx run crewforge review src/ -f sarif -o crewforge.sarif
196
+ env:
197
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
198
+ - uses: github/codeql-action/upload-sarif@v3
199
+ if: always()
200
+ with:
201
+ sarif_file: crewforge.sarif
202
+ ```
203
+
180
204
  ### `ask` and `summarize`
181
205
 
182
206
  <p align="center">
@@ -473,9 +497,13 @@ pip install crewforge # Python 3.10 hingga 3.14
473
497
  pip install "crewforge[docs,api]" # pilihan: PDF/DOCX dan semakan aplikasi FastAPI
474
498
  export OPENAI_API_KEY=sk-... # atau penyedia lain, lihat `crewforge providers`
475
499
  crewforge review src/
500
+ crewforge review src/ -f sarif -o crewforge.sarif # untuk GitHub code scanning
476
501
  crewforge academic feedback draf.md --brief taklimat.md --language ms
477
502
  ```
478
503
 
504
+ `review -f sarif` menulis laporan `SARIF` 2.1.0 yang dibaca GitHub code scanning: setiap dapatan menjadi satu
505
+ keputusan dalam tab Security, dan baris dalam semakan folder dipetakan kembali ke fail asalnya.
506
+
479
507
  Senarai penyedia, contoh setiap resipi dan seni bina ada dalam bahagian bahasa Inggeris di atas
480
508
  ([Providers](https://github.com/aimanmalib/crewforge#providers), [Recipes](https://github.com/aimanmalib/crewforge#recipes)).
481
509
 
@@ -87,7 +87,7 @@ tables are trimmed to their totals.
87
87
 
88
88
  | Command | Agents | What you get |
89
89
  |---|---|---|
90
- | `review PATH` | security, style, complexity, logic, testing, documentation (parallel) → summary | Findings by severity, a decision and a score; `-f json` for CI |
90
+ | `review PATH` | security, style, complexity, logic, testing, documentation (parallel) → summary | Findings by severity, a decision and a score; `-f json` for CI, `-f sarif` for GitHub code scanning |
91
91
  | `ingest FILES` | ingester, indexer (no LLM) | How each file was parsed and chunked |
92
92
  | `ask FILES -q Q [--verify] [--rerank]` | retriever → answerer → fact-checker | An answer with `[Source N]` citations and a claim-by-claim verdict |
93
93
  | `summarize FILES` | summarizer | Summary and key points per document |
@@ -119,6 +119,30 @@ $ crewforge review app.py -f markdown -o review.md # app.py builds SQL with
119
119
  total 7 calls 22,366 tokens (provider-reported)
120
120
  ```
121
121
 
122
+ `-f sarif` writes a [Static Analysis Results Interchange Format 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html)
123
+ log (`sarif`), the format GitHub code scanning reads, so findings can appear in a repository's Security tab. Each finding becomes one
124
+ result: `critical` and `error` map to level `error`, `warning` to `warning`, `info` to `note`; the rule is the
125
+ finding's CWE when it has one, otherwise the reviewer's name. When you review a directory, the line a reviewer
126
+ reports is mapped back to the file it came from. A finding whose line is missing or outside the code points at the
127
+ reviewed path without a line. Reviewers that fail are listed in `invocations`, and the exit code is the same as for
128
+ the other formats.
129
+
130
+ ```yaml
131
+ # .github/workflows/review.yml (needs an API key secret, e.g. OPENAI_API_KEY)
132
+ permissions:
133
+ contents: read
134
+ security-events: write
135
+ steps:
136
+ - uses: actions/checkout@v4
137
+ - run: pipx run crewforge review src/ -f sarif -o crewforge.sarif
138
+ env:
139
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
140
+ - uses: github/codeql-action/upload-sarif@v3
141
+ if: always()
142
+ with:
143
+ sarif_file: crewforge.sarif
144
+ ```
145
+
122
146
  ### `ask` and `summarize`
123
147
 
124
148
  <p align="center">
@@ -415,9 +439,13 @@ pip install crewforge # Python 3.10 hingga 3.14
415
439
  pip install "crewforge[docs,api]" # pilihan: PDF/DOCX dan semakan aplikasi FastAPI
416
440
  export OPENAI_API_KEY=sk-... # atau penyedia lain, lihat `crewforge providers`
417
441
  crewforge review src/
442
+ crewforge review src/ -f sarif -o crewforge.sarif # untuk GitHub code scanning
418
443
  crewforge academic feedback draf.md --brief taklimat.md --language ms
419
444
  ```
420
445
 
446
+ `review -f sarif` menulis laporan `SARIF` 2.1.0 yang dibaca GitHub code scanning: setiap dapatan menjadi satu
447
+ keputusan dalam tab Security, dan baris dalam semakan folder dipetakan kembali ke fail asalnya.
448
+
421
449
  Senarai penyedia, contoh setiap resipi dan seni bina ada dalam bahagian bahasa Inggeris di atas
422
450
  ([Providers](https://github.com/aimanmalib/crewforge#providers), [Recipes](https://github.com/aimanmalib/crewforge#recipes)).
423
451
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "crewforge"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "One crew of LLM agents, five recipes: code review, document Q&A, API scaffolding, content and honest academic-writing help — on any OpenAI-compatible provider."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """crewforge — one crew of LLM agents, five recipes, any OpenAI-compatible provider."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.3.0"
@@ -22,8 +22,14 @@ from ...cli_support import (
22
22
  from ...core.config import LLMConfig
23
23
  from .agents import REVIEW_AGENTS
24
24
  from .pipeline import CodeReviewCrew, ReviewResult
25
- from .report import report_dict, report_markdown
26
- from .sources import DEFAULT_MAX_FILE_BYTES, SourceError, collect, detect_language
25
+ from .report import report_dict, report_markdown, report_sarif
26
+ from .sources import (
27
+ DEFAULT_MAX_FILE_BYTES,
28
+ SourceError,
29
+ Sources,
30
+ collect,
31
+ detect_language,
32
+ )
27
33
 
28
34
  # Kept under the old private name: codeguard's tests and users imported it.
29
35
  _detect_language = detect_language
@@ -60,7 +66,8 @@ async def _run_review(
60
66
  "fmt",
61
67
  default="text",
62
68
  show_default=True,
63
- type=click.Choice(["text", "json", "markdown"]),
69
+ type=click.Choice(["text", "json", "markdown", "sarif"]),
70
+ help="sarif = SARIF 2.1.0 for GitHub code scanning.",
64
71
  )
65
72
  @click.option("--output", "-o", default="", help="Write the report to this file.")
66
73
  @click.option(
@@ -77,6 +84,7 @@ def review(ctx, path, path_opt, language, fmt, output, allow_partial):
77
84
  Example:
78
85
  crewforge review app.py
79
86
  crewforge review src/ -f json -o review.json
87
+ crewforge review src/ -f sarif -o review.sarif
80
88
  """
81
89
  target = path or path_opt
82
90
  if not target:
@@ -116,11 +124,18 @@ def review(ctx, path, path_opt, language, fmt, output, allow_partial):
116
124
  allow_partial=allow_partial,
117
125
  )
118
126
  )
119
- _display_results(result, fmt, output)
127
+ _display_results(result, fmt, output, sources, target)
120
128
 
121
129
 
122
- def _display_results(result: ReviewResult, fmt: str, output: str) -> None:
123
- text = json.dumps(report_dict(result), indent=2) if fmt == "json" else report_markdown(result)
130
+ def _display_results(
131
+ result: ReviewResult, fmt: str, output: str, sources: Sources, target: str
132
+ ) -> None:
133
+ if fmt == "json":
134
+ text = json.dumps(report_dict(result), indent=2)
135
+ elif fmt == "sarif":
136
+ text = json.dumps(report_sarif(result, sources, target), indent=2)
137
+ else:
138
+ text = report_markdown(result)
124
139
  if output:
125
140
  Path(output).write_text(text, encoding="utf-8")
126
141
  err_console.print(f"[green]Report written to {output}[/]")
@@ -0,0 +1,191 @@
1
+ """Render a :class:`ReviewResult` as JSON, SARIF or Markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ... import __version__
9
+ from .agents import SEVERITIES
10
+ from .pipeline import ReviewResult
11
+ from .sources import Sources
12
+
13
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
14
+ #: Finding severity -> SARIF ``level`` (GitHub code scanning shows error/warning/note).
15
+ SARIF_LEVELS = {"critical": "error", "error": "error", "warning": "warning", "info": "note"}
16
+
17
+
18
+ def report_dict(result: ReviewResult) -> dict[str, Any]:
19
+ """Machine-readable report: decision, per-agent findings/status, usage."""
20
+ summary = result.summary_result
21
+ return {
22
+ "status": result.status,
23
+ "language": result.language or "auto",
24
+ "decision": (summary.data.get("decision") if summary else None),
25
+ "quality_score": (summary.data.get("quality_score") if summary else None),
26
+ "total_findings": result.total_findings,
27
+ "total_tokens": result.total_tokens,
28
+ "duration_s": round(result.duration_s, 2),
29
+ "agents": {
30
+ name: {
31
+ "status": r.status,
32
+ "summary": r.summary,
33
+ "findings": r.findings,
34
+ "tokens": r.tokens_used,
35
+ "error": r.error,
36
+ }
37
+ for name, r in result.agent_results.items()
38
+ },
39
+ "summary": (
40
+ {
41
+ "status": summary.status,
42
+ "report": summary.summary,
43
+ "top_issues": summary.data.get("top_issues", []),
44
+ "error": summary.error,
45
+ }
46
+ if summary
47
+ else None
48
+ ),
49
+ "usage": result.usage,
50
+ }
51
+
52
+
53
+ def _finding_line(agent: str, f: dict[str, Any]) -> str:
54
+ where = f" (line {f['line']})" if f.get("line") not in (None, "") else ""
55
+ cwe = f" [{f['cwe']}]" if f.get("cwe") else ""
56
+ text = f"- **{f.get('title') or 'Untitled'}**{where}{cwe} — _{agent}_"
57
+ if f.get("description"):
58
+ text += f": {f['description']}"
59
+ if f.get("suggestion"):
60
+ text += f"\n - Fix: {f['suggestion']}"
61
+ return text
62
+
63
+
64
+ def report_markdown(result: ReviewResult) -> str:
65
+ """Human-readable Markdown report."""
66
+ lines = ["# crewforge review", ""]
67
+ summary = result.summary_result
68
+ if summary and summary.ok:
69
+ lines += [
70
+ f"**Decision:** {summary.data.get('decision')} · "
71
+ f"**Quality score:** {summary.data.get('quality_score')}/100",
72
+ "",
73
+ ]
74
+ if summary.summary.strip():
75
+ lines += ["## Summary", "", summary.summary.strip(), ""]
76
+ top = summary.data.get("top_issues") or []
77
+ if top:
78
+ lines += ["### Top issues", ""] + [f"{i}. {t}" for i, t in enumerate(top, 1)] + [""]
79
+ elif summary:
80
+ lines += ["## Summary", "", f"_Summary agent failed: {summary.error}_", ""]
81
+
82
+ lines += ["## Findings", ""]
83
+ any_finding = False
84
+ for severity in SEVERITIES:
85
+ rows = [
86
+ _finding_line(name, f)
87
+ for name, r in result.agent_results.items()
88
+ for f in r.findings
89
+ if f.get("severity") == severity
90
+ ]
91
+ if rows:
92
+ any_finding = True
93
+ lines += [f"### {severity.capitalize()} ({len(rows)})", ""] + rows + [""]
94
+ if not any_finding:
95
+ lines += ["_No findings reported._", ""]
96
+
97
+ lines += ["## Reviewers", ""]
98
+ for name, r in result.agent_results.items():
99
+ if r.ok:
100
+ note = r.summary.strip() or "no summary"
101
+ lines.append(f"- **{name}** ({r.finding_count} finding(s)): {note}")
102
+ else:
103
+ lines.append(f"- **{name}**: _Agent failed: {r.error}_")
104
+ lines.append("")
105
+ agents_run = len(result.agent_results) + (1 if summary else 0)
106
+ lines.append(
107
+ f"_Tokens: {result.total_tokens:,} (provider-reported) · "
108
+ f"Duration: {result.duration_s:.1f}s · Agents: {agents_run}_"
109
+ )
110
+ return "\n".join(lines) + "\n"
111
+
112
+
113
+ def _uri(path: Path) -> str:
114
+ """Artifact URI: relative paths stay relative (GitHub resolves them from the repo root)."""
115
+ return path.as_uri() if path.is_absolute() else path.as_posix()
116
+
117
+
118
+ def _int_line(value: Any) -> int | None:
119
+ if isinstance(value, bool):
120
+ return None
121
+ if isinstance(value, int):
122
+ return value if value > 0 else None
123
+ if isinstance(value, str) and value.strip().isdigit():
124
+ return int(value) or None
125
+ return None
126
+
127
+
128
+ def report_sarif(result: ReviewResult, sources: Sources, target: str | Path) -> dict[str, Any]:
129
+ """SARIF 2.1.0 log for GitHub code scanning: one ``result`` per finding.
130
+
131
+ A finding's line refers to the text the reviewers saw. For a directory that
132
+ is every file joined under ``# File:`` headers, so the line is mapped back to
133
+ its file; a line that cannot be mapped keeps the reviewed path, without a region.
134
+ """
135
+ results: list[dict[str, Any]] = []
136
+ rules: dict[str, dict[str, Any]] = {}
137
+ for agent, r in result.agent_results.items():
138
+ for f in r.findings:
139
+ rule_id = str(f.get("cwe") or agent)
140
+ rules.setdefault(rule_id, {"id": rule_id})
141
+ title = str(f.get("title") or "Untitled")
142
+ message = f"{title}: {f['description']}" if f.get("description") else title
143
+ if f.get("suggestion"):
144
+ message += f"\nFix: {f['suggestion']}"
145
+ physical: dict[str, Any] = {"artifactLocation": {"uri": _uri(Path(target))}}
146
+ line = _int_line(f.get("line"))
147
+ spot = sources.locate(line) if line is not None else None
148
+ if spot is not None:
149
+ physical = {
150
+ "artifactLocation": {"uri": _uri(spot[0])},
151
+ "region": {"startLine": spot[1]},
152
+ }
153
+ results.append(
154
+ {
155
+ "ruleId": rule_id,
156
+ "level": SARIF_LEVELS.get(str(f.get("severity")), "note"),
157
+ "message": {"text": message},
158
+ "locations": [{"physicalLocation": physical}],
159
+ "properties": {"agent": agent, "severity": f.get("severity", "info")},
160
+ }
161
+ )
162
+ return {
163
+ "$schema": SARIF_SCHEMA,
164
+ "version": "2.1.0",
165
+ "runs": [
166
+ {
167
+ "tool": {
168
+ "driver": {
169
+ "name": "crewforge",
170
+ "version": __version__,
171
+ "informationUri": "https://github.com/aimanmalib/crewforge",
172
+ "rules": list(rules.values()),
173
+ }
174
+ },
175
+ "invocations": [
176
+ {
177
+ "executionSuccessful": not result.failed_agents,
178
+ "toolExecutionNotifications": [
179
+ {
180
+ "level": "error",
181
+ "message": {"text": f"agent '{name}' failed: {r.error}"},
182
+ }
183
+ for name, r in result.agent_results.items()
184
+ if not r.ok
185
+ ],
186
+ }
187
+ ],
188
+ "results": results,
189
+ }
190
+ ],
191
+ }
@@ -52,6 +52,19 @@ class Sources:
52
52
  language: str
53
53
  files: list[Path] = field(default_factory=list)
54
54
  skipped: list[str] = field(default_factory=list)
55
+ #: ``(file, first, last)``: lines ``first..last`` of :attr:`code` hold line 1.. of ``file``.
56
+ spans: list[tuple[Path, int, int]] = field(default_factory=list)
57
+
58
+ def locate(self, line: int) -> tuple[Path, int] | None:
59
+ """Map a line of :attr:`code` to ``(file, line in that file)``.
60
+
61
+ ``None`` when the line is outside the code or falls on a ``# File:``
62
+ header or the blank lines between two files.
63
+ """
64
+ for path, first, last in self.spans:
65
+ if first <= line <= last:
66
+ return path, line - first + 1
67
+ return None
55
68
 
56
69
 
57
70
  def collect(path: str | Path, *, max_file_bytes: int = DEFAULT_MAX_FILE_BYTES) -> Sources:
@@ -60,13 +73,20 @@ def collect(path: str | Path, *, max_file_bytes: int = DEFAULT_MAX_FILE_BYTES) -
60
73
  code = target.read_text(encoding="utf-8", errors="replace")
61
74
  if not code.strip():
62
75
  raise SourceError(f"{target} is empty")
63
- return Sources(code=code, language=detect_language(target.suffix), files=[target])
76
+ return Sources(
77
+ code=code,
78
+ language=detect_language(target.suffix),
79
+ files=[target],
80
+ spans=[(target, 1, _line_count(code))],
81
+ )
64
82
  if not target.is_dir():
65
83
  raise SourceError(f"path not found: {target}")
66
84
 
67
85
  parts: list[str] = []
68
86
  files: list[Path] = []
69
87
  skipped: list[str] = []
88
+ spans: list[tuple[Path, int, int]] = []
89
+ start = 1 # line of ``code`` where the next "# File:" header goes
70
90
  languages: set[str] = set()
71
91
  for f in sorted(target.rglob("*")):
72
92
  rel = f.relative_to(target)
@@ -80,10 +100,20 @@ def collect(path: str | Path, *, max_file_bytes: int = DEFAULT_MAX_FILE_BYTES) -
80
100
  text = f.read_text(encoding="utf-8", errors="replace")
81
101
  if not text.strip():
82
102
  continue
83
- parts.append(f"# File: {rel}\n{text}")
103
+ part = f"# File: {rel}\n{text}"
104
+ parts.append(part)
84
105
  files.append(f)
106
+ spans.append((f, start + 1, start + _line_count(text)))
107
+ start += part.count("\n") + 2 # the part, then the blank line joining the next one
85
108
  languages.add(detect_language(f.suffix))
86
109
  if not parts:
87
110
  raise SourceError(f"no supported source files found under {target}")
88
111
  language = languages.pop() if len(languages) == 1 else ""
89
- return Sources(code="\n\n".join(parts), language=language, files=files, skipped=skipped)
112
+ return Sources(
113
+ code="\n\n".join(parts), language=language, files=files, skipped=skipped, spans=spans
114
+ )
115
+
116
+
117
+ def _line_count(text: str) -> int:
118
+ """Lines in ``text`` as an editor numbers them (a final newline adds none)."""
119
+ return text.count("\n") + (0 if text.endswith("\n") else 1)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crewforge
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: One crew of LLM agents, five recipes: code review, document Q&A, API scaffolding, content and honest academic-writing help — on any OpenAI-compatible provider.
5
5
  Author: Muhammad Aiman
6
6
  License-Expression: MIT
@@ -145,7 +145,7 @@ tables are trimmed to their totals.
145
145
 
146
146
  | Command | Agents | What you get |
147
147
  |---|---|---|
148
- | `review PATH` | security, style, complexity, logic, testing, documentation (parallel) → summary | Findings by severity, a decision and a score; `-f json` for CI |
148
+ | `review PATH` | security, style, complexity, logic, testing, documentation (parallel) → summary | Findings by severity, a decision and a score; `-f json` for CI, `-f sarif` for GitHub code scanning |
149
149
  | `ingest FILES` | ingester, indexer (no LLM) | How each file was parsed and chunked |
150
150
  | `ask FILES -q Q [--verify] [--rerank]` | retriever → answerer → fact-checker | An answer with `[Source N]` citations and a claim-by-claim verdict |
151
151
  | `summarize FILES` | summarizer | Summary and key points per document |
@@ -177,6 +177,30 @@ $ crewforge review app.py -f markdown -o review.md # app.py builds SQL with
177
177
  total 7 calls 22,366 tokens (provider-reported)
178
178
  ```
179
179
 
180
+ `-f sarif` writes a [Static Analysis Results Interchange Format 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html)
181
+ log (`sarif`), the format GitHub code scanning reads, so findings can appear in a repository's Security tab. Each finding becomes one
182
+ result: `critical` and `error` map to level `error`, `warning` to `warning`, `info` to `note`; the rule is the
183
+ finding's CWE when it has one, otherwise the reviewer's name. When you review a directory, the line a reviewer
184
+ reports is mapped back to the file it came from. A finding whose line is missing or outside the code points at the
185
+ reviewed path without a line. Reviewers that fail are listed in `invocations`, and the exit code is the same as for
186
+ the other formats.
187
+
188
+ ```yaml
189
+ # .github/workflows/review.yml (needs an API key secret, e.g. OPENAI_API_KEY)
190
+ permissions:
191
+ contents: read
192
+ security-events: write
193
+ steps:
194
+ - uses: actions/checkout@v4
195
+ - run: pipx run crewforge review src/ -f sarif -o crewforge.sarif
196
+ env:
197
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
198
+ - uses: github/codeql-action/upload-sarif@v3
199
+ if: always()
200
+ with:
201
+ sarif_file: crewforge.sarif
202
+ ```
203
+
180
204
  ### `ask` and `summarize`
181
205
 
182
206
  <p align="center">
@@ -473,9 +497,13 @@ pip install crewforge # Python 3.10 hingga 3.14
473
497
  pip install "crewforge[docs,api]" # pilihan: PDF/DOCX dan semakan aplikasi FastAPI
474
498
  export OPENAI_API_KEY=sk-... # atau penyedia lain, lihat `crewforge providers`
475
499
  crewforge review src/
500
+ crewforge review src/ -f sarif -o crewforge.sarif # untuk GitHub code scanning
476
501
  crewforge academic feedback draf.md --brief taklimat.md --language ms
477
502
  ```
478
503
 
504
+ `review -f sarif` menulis laporan `SARIF` 2.1.0 yang dibaca GitHub code scanning: setiap dapatan menjadi satu
505
+ keputusan dalam tab Security, dan baris dalam semakan folder dipetakan kembali ke fail asalnya.
506
+
479
507
  Senarai penyedia, contoh setiap resipi dan seni bina ada dalam bahagian bahasa Inggeris di atas
480
508
  ([Providers](https://github.com/aimanmalib/crewforge#providers), [Recipes](https://github.com/aimanmalib/crewforge#recipes)).
481
509
 
@@ -1,103 +0,0 @@
1
- """Render a :class:`ReviewResult` as JSON or Markdown."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
-
7
- from .agents import SEVERITIES
8
- from .pipeline import ReviewResult
9
-
10
-
11
- def report_dict(result: ReviewResult) -> dict[str, Any]:
12
- """Machine-readable report: decision, per-agent findings/status, usage."""
13
- summary = result.summary_result
14
- return {
15
- "status": result.status,
16
- "language": result.language or "auto",
17
- "decision": (summary.data.get("decision") if summary else None),
18
- "quality_score": (summary.data.get("quality_score") if summary else None),
19
- "total_findings": result.total_findings,
20
- "total_tokens": result.total_tokens,
21
- "duration_s": round(result.duration_s, 2),
22
- "agents": {
23
- name: {
24
- "status": r.status,
25
- "summary": r.summary,
26
- "findings": r.findings,
27
- "tokens": r.tokens_used,
28
- "error": r.error,
29
- }
30
- for name, r in result.agent_results.items()
31
- },
32
- "summary": (
33
- {
34
- "status": summary.status,
35
- "report": summary.summary,
36
- "top_issues": summary.data.get("top_issues", []),
37
- "error": summary.error,
38
- }
39
- if summary
40
- else None
41
- ),
42
- "usage": result.usage,
43
- }
44
-
45
-
46
- def _finding_line(agent: str, f: dict[str, Any]) -> str:
47
- where = f" (line {f['line']})" if f.get("line") not in (None, "") else ""
48
- cwe = f" [{f['cwe']}]" if f.get("cwe") else ""
49
- text = f"- **{f.get('title') or 'Untitled'}**{where}{cwe} — _{agent}_"
50
- if f.get("description"):
51
- text += f": {f['description']}"
52
- if f.get("suggestion"):
53
- text += f"\n - Fix: {f['suggestion']}"
54
- return text
55
-
56
-
57
- def report_markdown(result: ReviewResult) -> str:
58
- """Human-readable Markdown report."""
59
- lines = ["# crewforge review", ""]
60
- summary = result.summary_result
61
- if summary and summary.ok:
62
- lines += [
63
- f"**Decision:** {summary.data.get('decision')} · "
64
- f"**Quality score:** {summary.data.get('quality_score')}/100",
65
- "",
66
- ]
67
- if summary.summary.strip():
68
- lines += ["## Summary", "", summary.summary.strip(), ""]
69
- top = summary.data.get("top_issues") or []
70
- if top:
71
- lines += ["### Top issues", ""] + [f"{i}. {t}" for i, t in enumerate(top, 1)] + [""]
72
- elif summary:
73
- lines += ["## Summary", "", f"_Summary agent failed: {summary.error}_", ""]
74
-
75
- lines += ["## Findings", ""]
76
- any_finding = False
77
- for severity in SEVERITIES:
78
- rows = [
79
- _finding_line(name, f)
80
- for name, r in result.agent_results.items()
81
- for f in r.findings
82
- if f.get("severity") == severity
83
- ]
84
- if rows:
85
- any_finding = True
86
- lines += [f"### {severity.capitalize()} ({len(rows)})", ""] + rows + [""]
87
- if not any_finding:
88
- lines += ["_No findings reported._", ""]
89
-
90
- lines += ["## Reviewers", ""]
91
- for name, r in result.agent_results.items():
92
- if r.ok:
93
- note = r.summary.strip() or "no summary"
94
- lines.append(f"- **{name}** ({r.finding_count} finding(s)): {note}")
95
- else:
96
- lines.append(f"- **{name}**: _Agent failed: {r.error}_")
97
- lines.append("")
98
- agents_run = len(result.agent_results) + (1 if summary else 0)
99
- lines.append(
100
- f"_Tokens: {result.total_tokens:,} (provider-reported) · "
101
- f"Duration: {result.duration_s:.1f}s · Agents: {agents_run}_"
102
- )
103
- return "\n".join(lines) + "\n"
File without changes
File without changes