pyfltr 2.2.0__tar.gz → 2.2.2__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 (65) hide show
  1. {pyfltr-2.2.0 → pyfltr-2.2.2}/CLAUDE.md +1 -10
  2. {pyfltr-2.2.0 → pyfltr-2.2.2}/PKG-INFO +2 -2
  3. {pyfltr-2.2.0 → pyfltr-2.2.2}/README.md +1 -1
  4. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/guide/usage.md +7 -5
  5. {pyfltr-2.2.0 → pyfltr-2.2.2}/mkdocs.yml +3 -2
  6. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/cli.py +12 -2
  7. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/command.py +1 -1
  8. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/error_parser.py +67 -19
  9. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/llm_output.py +64 -20
  10. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/main.py +30 -11
  11. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/error_parser_test.py +66 -10
  12. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/output_format_test.py +109 -6
  13. pyfltr-2.2.0/.claude/settings.json +0 -13
  14. {pyfltr-2.2.0 → pyfltr-2.2.2}/.claude/agents/error-parser-reviewer.md +0 -0
  15. {pyfltr-2.2.0 → pyfltr-2.2.2}/.claude/agents/tool-compat-checker.md +0 -0
  16. {pyfltr-2.2.0 → pyfltr-2.2.2}/.claude/skills/pyfltr-add-tool/SKILL.md +0 -0
  17. {pyfltr-2.2.0 → pyfltr-2.2.2}/.editorconfig +0 -0
  18. {pyfltr-2.2.0 → pyfltr-2.2.2}/.gitattributes +0 -0
  19. {pyfltr-2.2.0 → pyfltr-2.2.2}/.github/workflows/ci.yaml +0 -0
  20. {pyfltr-2.2.0 → pyfltr-2.2.2}/.github/workflows/docs.yaml +0 -0
  21. {pyfltr-2.2.0 → pyfltr-2.2.2}/.github/workflows/release.yaml +0 -0
  22. {pyfltr-2.2.0 → pyfltr-2.2.2}/.gitignore +0 -0
  23. {pyfltr-2.2.0 → pyfltr-2.2.2}/.gitmessage +0 -0
  24. {pyfltr-2.2.0 → pyfltr-2.2.2}/.markdownlint-cli2.yaml +0 -0
  25. {pyfltr-2.2.0 → pyfltr-2.2.2}/.npmrc +0 -0
  26. {pyfltr-2.2.0 → pyfltr-2.2.2}/.pre-commit-config.yaml +0 -0
  27. {pyfltr-2.2.0 → pyfltr-2.2.2}/.pylintrc +0 -0
  28. {pyfltr-2.2.0 → pyfltr-2.2.2}/.python-version +0 -0
  29. {pyfltr-2.2.0 → pyfltr-2.2.2}/.textlintrc.yaml +0 -0
  30. {pyfltr-2.2.0 → pyfltr-2.2.2}/.vscode/extensions.json +0 -0
  31. {pyfltr-2.2.0 → pyfltr-2.2.2}/.vscode/settings.json +0 -0
  32. {pyfltr-2.2.0 → pyfltr-2.2.2}/LICENSE +0 -0
  33. {pyfltr-2.2.0 → pyfltr-2.2.2}/Makefile +0 -0
  34. {pyfltr-2.2.0 → pyfltr-2.2.2}/cliff.toml +0 -0
  35. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/.markdownlint-cli2.yaml +0 -0
  36. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/development/development.md +0 -0
  37. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/development/index.md +0 -0
  38. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/guide/configuration-tools.md +0 -0
  39. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/guide/configuration.md +0 -0
  40. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/guide/custom-commands.md +0 -0
  41. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/guide/index.md +0 -0
  42. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/guide/recommended-nonpython.md +0 -0
  43. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/guide/recommended.md +0 -0
  44. {pyfltr-2.2.0 → pyfltr-2.2.2}/docs/index.md +0 -0
  45. {pyfltr-2.2.0 → pyfltr-2.2.2}/mise.toml +0 -0
  46. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/__init__.py +0 -0
  47. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/__main__.py +0 -0
  48. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/config.py +0 -0
  49. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/executor.py +0 -0
  50. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/precommit.py +0 -0
  51. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/ui.py +0 -0
  52. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyfltr/warnings_.py +0 -0
  53. {pyfltr-2.2.0 → pyfltr-2.2.2}/pyproject.toml +0 -0
  54. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/__init__.py +0 -0
  55. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/cli_test.py +0 -0
  56. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/command_test.py +0 -0
  57. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/config_test.py +0 -0
  58. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/conftest.py +0 -0
  59. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/executor_test.py +0 -0
  60. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/llm_output_test.py +0 -0
  61. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/main_test.py +0 -0
  62. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/precommit_test.py +0 -0
  63. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/ui_test.py +0 -0
  64. {pyfltr-2.2.0 → pyfltr-2.2.2}/tests/warnings_test.py +0 -0
  65. {pyfltr-2.2.0 → pyfltr-2.2.2}/uv.lock +0 -0
@@ -6,7 +6,7 @@
6
6
  - `make update-actions`: GitHub Actionsのハッシュピン更新のみ(mise経由でpinact実行)
7
7
  - テストコードは`pyfltr/xxx_.py`に対して`tests/xxx_test.py`として配置する
8
8
  - 実行パイプラインの構造: `run_pipeline`(main.py)がTUI/非TUI分岐の最上位関数。パイプライン共通の前処理(ファイル展開など)はこの関数内でTUI起動前に実行する
9
- - コミット前の検証方法: `uv run pyfltr run-for-agent | tail -30`
9
+ - コミット前の検証方法: `uv run pyfltr run-for-agent`
10
10
  - ドキュメントなどのみの変更の場合は省略可(pre-commitで実行されるため)
11
11
  - テストコードの単体実行なども極力 `uv run pyfltr run-for-agent <path>` を使う(pytestを直接呼び出さない)
12
12
  - 詳細な情報などが必要な場合に限り `uv run pytest -vv <path>` などを使用
@@ -16,12 +16,3 @@
16
16
  - `uv run mkdocs build --strict`でリンク・nav整合性を検証(ただし日本語アンカーリンク`#見出し日本語`はMkDocs TOCで解決できずINFO通知のみで`--strict`でも検知されないため手動確認要)
17
17
  - `docs/guide/index.md`の対応ツール一覧と`mkdocs.yml`内llmstxt `markdown_description`の「対応ツール」節は人手同期(SSOT化しない運用)
18
18
  - ドキュメント構成変更時は`docs/development/development.md`の「READMEとdocsの役割分担」節を先に参照
19
-
20
- ## 関連ドキュメント
21
-
22
- - @README.md
23
- - @docs/index.md
24
- - @docs/guide/index.md
25
- - @docs/development/index.md
26
- - @docs/development/development.md
27
- - ドキュメント追加時は `mkdocs.yml` の `nav` を更新要
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyfltr
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: Python Formatters, Linters, and Testers Runner.
5
5
  Project-URL: Homepage, https://github.com/ak110/pyfltr
6
6
  Author-email: "aki." <mark@aur.ll.to>
@@ -54,7 +54,7 @@ Description-Content-Type: text/markdown
54
54
  - 設定の集約: `pyproject.toml`に寄せた統一設定
55
55
  - 除外指定(exclude)の書式差をツール間で吸収
56
56
  - 自動修正系ツール(ruff format・prettierなど)を修正と失敗扱いの両立で実行
57
- - LLMエージェント向けJSON Lines出力(`--output-format=jsonl`)に対応
57
+ - LLMエージェント向けJSON Lines出力(`pyfltr run-for-agent`・`PYFLTR_OUTPUT_FORMAT`環境変数・`--output-format=jsonl`)に対応
58
58
 
59
59
  ## インストール
60
60
 
@@ -12,7 +12,7 @@
12
12
  - 設定の集約: `pyproject.toml`に寄せた統一設定
13
13
  - 除外指定(exclude)の書式差をツール間で吸収
14
14
  - 自動修正系ツール(ruff format・prettierなど)を修正と失敗扱いの両立で実行
15
- - LLMエージェント向けJSON Lines出力(`--output-format=jsonl`)に対応
15
+ - LLMエージェント向けJSON Lines出力(`pyfltr run-for-agent`・`PYFLTR_OUTPUT_FORMAT`環境変数・`--output-format=jsonl`)に対応
16
16
 
17
17
  ## インストール
18
18
 
@@ -81,7 +81,7 @@ pyfltr generate-config
81
81
  - markdownlint / textlint: `*.md`
82
82
  - pytest: `*_test.py`
83
83
 
84
- ### `fast` / `run` / `ci`の動作の違いと自動修正(fixステージ)
84
+ ### `fast` / `run` / `run-for-agent` / `ci`の動作の違いと自動修正(fixステージ)
85
85
 
86
86
  各サブコマンドの主な違いを以下に示す(軽い順)。
87
87
 
@@ -191,7 +191,9 @@ CLIオプション`--output-format`が指定されている場合は環境変数
191
191
  {"kind":"summary","total":2,"succeeded":0,"formatted":1,"failed":1,"skipped":0,"diagnostics":1,"exit":1}
192
192
  ```
193
193
 
194
- 出力順は`warning`→`diagnostic`(file/line/col/command順)→`tool`(`pyproject.toml`の定義順)→`summary`(1行)で固定。
194
+ stdoutモード(`--output-file`未指定)では、各ツールの完了時にdiagnostic行+tool行を随時書き出す。
195
+ ツール間の出力順は完了順となり、最後にwarning行+summary行が続く。
196
+ ファイル出力時(`--output-file`指定)では、`pyproject.toml`の定義順にツール単位でグルーピングし、先頭にwarning行、末尾にsummary行を配置する。
195
197
 
196
198
  `warning`レコードの`source`は`config`(設定ファイル不在など)/`tool-resolve`(ツール解決失敗)/`file-resolver`(対象ファイル選定時)/`git`(`git check-ignore`失敗)のいずれか。
197
199
 
@@ -212,7 +214,7 @@ LLMエージェントがpyfltrを活用する基本的な流れ:
212
214
  1. 全体実行でsummaryを確認する
213
215
 
214
216
  ```shell
215
- pyfltr run-for-agent | tail -30
217
+ pyfltr run-for-agent
216
218
  ```
217
219
 
218
220
  末尾のsummary行(`"kind":"summary"`)で`failed`の有無と`diagnostics`数を確認し、問題がなければ完了する。
@@ -220,11 +222,11 @@ LLMエージェントがpyfltrを活用する基本的な流れ:
220
222
  2. 問題があるツール/ファイルだけ個別に再実行する
221
223
 
222
224
  ```shell
223
- pyfltr run-for-agent --commands=mypy path/to/file.py | tail -30
225
+ pyfltr run-for-agent --commands=mypy path/to/file.py
224
226
  ```
225
227
 
226
228
  `--commands`で特定ツールに絞ることで出力量を抑えつつ、`diagnostic`行から修正対象のファイル・行番号・メッセージを取得する。
227
- 詳細が必要な場合に限り`--output-format=text`で再実行するなど、段階的に情報を掘り下げることも可能。
229
+ 詳細が必要な場合に限り`run`で再実行するなど、段階的に情報を掘り下げることも可能。
228
230
 
229
231
  ## pre-commitとの統合
230
232
 
@@ -38,9 +38,10 @@ plugins:
38
38
  - `pyfltr ci`: 全チェック。formatterによる変更も失敗と判定する(CI向け)
39
39
  - `pyfltr run`: 全チェック。fix段→formatter段→linter/tester段の順で、fix-args定義済みlinterの自動修正→formatter→残りの検査を実行する(ローカル向け)
40
40
  - `pyfltr fast`: 軽量チェック。`run`と同じ3段構成だが、mypy/pylint/pytest等の重いツールを除外(pre-commit向け)
41
+ - `pyfltr run-for-agent`: `pyfltr run --output-format=jsonl`のエイリアス。JSONL出力を既定にする(LLMエージェント向け)
41
42
  - `pyfltr generate-config`: pyproject.toml用の設定雛形を出力
42
43
 
43
- `--no-fix`で`run`/`fast`のfix段を抑止できる。`ci`は副作用回避のため元々fix段を持たない。
44
+ `--no-fix`で`run`/`fast`/`run-for-agent`のfix段を抑止できる。`ci`は副作用回避のため元々fix段を持たない。
44
45
 
45
46
  ## 対応ツール
46
47
 
@@ -79,7 +80,7 @@ plugins:
79
80
  `--output-file`未指定時はstdoutへJSONLのみを書き、進捗ログやTUIは無音化される。指定時はファイルへJSONLを書き、stdoutには従来どおりのテキスト出力を並行出力する(ローカル実行で進捗を追える)。
80
81
 
81
82
  ```shell
82
- pyfltr run-for-agent | tail -30
83
+ pyfltr run-for-agent
83
84
  ```
84
85
 
85
86
  `pyfltr run-for-agent`は`pyfltr run --output-format=jsonl`と等価のエイリアス。
@@ -29,6 +29,7 @@ def run_commands_with_cli(
29
29
  *,
30
30
  per_command_log: bool,
31
31
  include_fix_stage: bool = False,
32
+ on_result: typing.Callable[[pyfltr.command.CommandResult], None] | None = None,
32
33
  ) -> list[pyfltr.command.CommandResult]:
33
34
  """コマンドを実行する (非 TUI)。
34
35
 
@@ -40,6 +41,9 @@ def run_commands_with_cli(
40
41
  ``include_fix_stage=True`` のとき、fix-args 定義済みコマンドを先に ``--fix`` 付きで
41
42
  直列実行してから、formatter → linter/tester の順で通常実行に進む
42
43
  (``ruff check --fix → ruff format → ruff check`` と同じ 2 段階方式の一般化)。
44
+
45
+ ``on_result`` が指定されている場合、各コマンド完了時にコールバックを呼び出す。
46
+ JSONL stdoutモードでのストリーミング出力に使用する。
43
47
  """
44
48
  results: list[pyfltr.command.CommandResult] = []
45
49
  fixers, formatters, linters_and_testers = pyfltr.executor.split_commands_for_execution(
@@ -54,7 +58,10 @@ def run_commands_with_cli(
54
58
 
55
59
  # formatters を順序実行
56
60
  for command in formatters:
57
- results.append(_run_one_command(command, args, config, all_files, per_command_log=per_command_log))
61
+ result = _run_one_command(command, args, config, all_files, per_command_log=per_command_log)
62
+ results.append(result)
63
+ if on_result is not None:
64
+ on_result(result)
58
65
 
59
66
  # linters/testers を並列実行
60
67
  if len(linters_and_testers) > 0:
@@ -64,7 +71,10 @@ def run_commands_with_cli(
64
71
  for command in linters_and_testers
65
72
  }
66
73
  for future in concurrent.futures.as_completed(future_to_command):
67
- results.append(future.result())
74
+ result = future.result()
75
+ results.append(result)
76
+ if on_result is not None:
77
+ on_result(result)
68
78
 
69
79
  return results
70
80
 
@@ -121,7 +121,7 @@ _STRUCTURED_OUTPUT_SPECS: dict[str, tuple[str, _StructuredOutputSpec]] = {
121
121
  "pytest-tb-line": (
122
122
  "pytest",
123
123
  _StructuredOutputSpec(
124
- inject=["--tb=line"],
124
+ inject=["--tb=short"],
125
125
  conflicts=["--tb"],
126
126
  ),
127
127
  ),
@@ -427,28 +427,58 @@ def _parse_typos_jsonl(output: str) -> list[ErrorLocation]:
427
427
 
428
428
 
429
429
  def _parse_pytest(output: str) -> list[ErrorLocation]:
430
- """Pytest 出力をパース。--tb=line 形式の行番号付き出力を優先的に抽出する。"""
431
- # --tb=line 出力行を探す: /path/to/test.py:42: assert message
432
- tb_line_re = re.compile(rf"^(?P<file>{_FILE}):(?P<line>\d+):\s+(?P<message>.+)$", re.MULTILINE)
433
- # FAILURES セクション内の --tb=line 出力のみを対象にする
430
+ """Pytest出力をパース。--tb=short形式のトレースバックからプロジェクト内フレームを優先的に抽出する。"""
434
431
  failures_start = output.find("= FAILURES =")
435
432
  summary_start = output.find("short test summary info")
436
- if failures_start >= 0:
437
- end = summary_start if summary_start > failures_start else len(output)
438
- failures_section = output[failures_start:end]
439
- tb_results: list[ErrorLocation] = []
440
- for match in tb_line_re.finditer(failures_section):
441
- tb_results.append(
442
- ErrorLocation(
443
- file=_normalize_path(match.group("file")),
444
- line=int(match.group("line")),
445
- col=None,
446
- command="pytest",
447
- message=match.group("message").strip(),
448
- )
433
+ if failures_start < 0:
434
+ return _parse_with_pattern("pytest", output, _BUILTIN_PATTERNS["pytest"])
435
+
436
+ end = summary_start if summary_start > failures_start else len(output)
437
+ failures_section = output[failures_start:end]
438
+
439
+ # テスト単位のブロックに分割(`_ test_name _` 区切り)
440
+ block_re = re.compile(r"^_+ .+ _+$", re.MULTILINE)
441
+ block_starts = [m.end() for m in block_re.finditer(failures_section)]
442
+ if not block_starts:
443
+ return _parse_with_pattern("pytest", output, _BUILTIN_PATTERNS["pytest"])
444
+
445
+ # フレーム行: file:line: in func_name
446
+ frame_re = re.compile(rf"^(?P<file>{_FILE}):(?P<line>\d+): in .+$", re.MULTILINE)
447
+ # エラー行: E message
448
+ error_re = re.compile(r"^E\s+(?P<message>.+)$", re.MULTILINE)
449
+
450
+ results: list[ErrorLocation] = []
451
+ for i, start in enumerate(block_starts):
452
+ block_end = block_starts[i + 1] if i + 1 < len(block_starts) else len(failures_section)
453
+ block = failures_section[start:block_end]
454
+
455
+ # フレーム群から最後のプロジェクト内フレームを選択
456
+ frames = list(frame_re.finditer(block))
457
+ if not frames:
458
+ continue
459
+
460
+ chosen = frames[-1] # フォールバック: 最後のフレーム
461
+ for frame in reversed(frames):
462
+ if _is_project_path(_normalize_path(frame.group("file"))):
463
+ chosen = frame
464
+ break
465
+
466
+ # エラーメッセージ(先頭のE行)
467
+ error_match = error_re.search(block)
468
+ message = error_match.group("message").strip() if error_match else ""
469
+
470
+ results.append(
471
+ ErrorLocation(
472
+ file=_normalize_path(chosen.group("file")),
473
+ line=int(chosen.group("line")),
474
+ col=None,
475
+ command="pytest",
476
+ message=message,
449
477
  )
450
- if tb_results:
451
- return tb_results
478
+ )
479
+
480
+ if results:
481
+ return results
452
482
  # フォールバック: FAILED file::test_name パターン(line=0)
453
483
  return _parse_with_pattern("pytest", output, _BUILTIN_PATTERNS["pytest"])
454
484
 
@@ -576,3 +606,21 @@ def _normalize_path(file_path: str) -> str:
576
606
  return file_path
577
607
  return result.replace("\\", "/")
578
608
  return file_path.replace("\\", "/")
609
+
610
+
611
+ def _is_project_path(normalized_path: str) -> bool:
612
+ """正規化済みパスがプロジェクト内のファイルかを判定する。
613
+
614
+ 以下を全て満たす場合にプロジェクト内と見なす:
615
+ - 相対パスである(絶対パスはcwd外 = 標準ライブラリ等)
616
+ - ``..``で始まらない(uv管理Pythonの標準ライブラリ等)
617
+ - ``.venv/``で始まらない(仮想環境内サードパーティー)
618
+ - ``site-packages/``・``dist-packages/``を含まない(名前の異なる仮想環境内サードパーティー)
619
+ """
620
+ if pathlib.PurePosixPath(normalized_path).is_absolute():
621
+ return False
622
+ if normalized_path.startswith(".."):
623
+ return False
624
+ if normalized_path.startswith(".venv/"):
625
+ return False
626
+ return not ("site-packages/" in normalized_path or "dist-packages/" in normalized_path)
@@ -8,12 +8,17 @@
8
8
  import json
9
9
  import pathlib
10
10
  import sys
11
+ import threading
11
12
  import typing
12
13
 
13
14
  import pyfltr.command
14
15
  import pyfltr.config
15
16
  import pyfltr.error_parser
16
17
 
18
+ # ストリーミング書き出し時に複数行(diagnostic行+tool行)をアトミックに出力するためのロック。
19
+ # 並列実行される linters/testers から同時にコールバックが呼ばれる可能性がある。
20
+ _write_lock = threading.Lock()
21
+
17
22
  # failed かつ diagnostics=0 のときに tool.message として載せる生出力のトリム上限。
18
23
  # 末尾 30 行を取り出し、さらに末尾 2000 文字に切り詰める。
19
24
  _MESSAGE_MAX_LINES = 30
@@ -21,6 +26,22 @@ _MESSAGE_MAX_CHARS = 2000
21
26
  _TRUNCATED_PREFIX = "... (truncated)\n"
22
27
 
23
28
 
29
+ def build_tool_lines(
30
+ result: pyfltr.command.CommandResult,
31
+ config: pyfltr.config.Config,
32
+ ) -> list[str]:
33
+ """1コマンド分のdiagnostic行+tool行をJSONL文字列のリストとして生成する。
34
+
35
+ diagnostic行はツール内でソートされる。
36
+ """
37
+ sorted_errors = pyfltr.error_parser.sort_errors(result.errors, config.command_names)
38
+ lines: list[str] = []
39
+ for error in sorted_errors:
40
+ lines.append(_dump(_build_diagnostic_record(error)))
41
+ lines.append(_dump(_build_tool_record(result, diagnostics=len(result.errors))))
42
+ return lines
43
+
44
+
24
45
  def build_lines(
25
46
  results: list[pyfltr.command.CommandResult],
26
47
  config: pyfltr.config.Config,
@@ -28,16 +49,15 @@ def build_lines(
28
49
  exit_code: int,
29
50
  warnings: list[dict[str, typing.Any]] | None = None,
30
51
  ) -> list[str]:
31
- """CommandResult 群から JSONL 各行を生成する。
52
+ """CommandResult群からJSONL各行を生成する。
32
53
 
33
54
  出力順:
34
- 1. `warnings` が非空なら kind="warning" 行(先頭)
35
- 2. 全診断を (file, line, col, command 順) で昇順ソートした diagnostic
36
- 3. config.command_names の定義順に並べた tool
37
- 4. summary 行 1 行
55
+ 1. ``warnings``が非空ならkind="warning"
56
+ 2. ツール単位でdiagnostic行+tool行(``config.command_names``の定義順)
57
+ 3. summary1行
38
58
 
39
- results は順序を問わない。内部で `config.command_names` 順にソートする。
40
- ``warnings`` は `pyfltr.warnings_.collected_warnings()` の返り値を想定する。
59
+ resultsは順序を問わない。内部で``config.command_names``順にソートする。
60
+ ``warnings``は``pyfltr.warnings_.collected_warnings()``の返り値を想定する。
41
61
  """
42
62
  ordered = sorted(results, key=lambda r: _command_index(config, r.command))
43
63
 
@@ -46,20 +66,8 @@ def build_lines(
46
66
  for warning in warnings or []:
47
67
  lines.append(_dump(_build_warning_record(warning)))
48
68
 
49
- all_errors: list[pyfltr.error_parser.ErrorLocation] = []
50
69
  for result in ordered:
51
- all_errors.extend(result.errors)
52
- sorted_errors = pyfltr.error_parser.sort_errors(all_errors, config.command_names)
53
- for error in sorted_errors:
54
- lines.append(_dump(_build_diagnostic_record(error)))
55
-
56
- diagnostic_counts: dict[str, int] = {}
57
- for error in all_errors:
58
- diagnostic_counts[error.command] = diagnostic_counts.get(error.command, 0) + 1
59
-
60
- for result in ordered:
61
- diagnostics = diagnostic_counts.get(result.command, 0)
62
- lines.append(_dump(_build_tool_record(result, diagnostics=diagnostics)))
70
+ lines.extend(build_tool_lines(result, config))
63
71
 
64
72
  lines.append(_dump(_build_summary_record(ordered, exit_code=exit_code)))
65
73
  return lines
@@ -100,6 +108,42 @@ def write_jsonl(
100
108
  f.write("\n")
101
109
 
102
110
 
111
+ def write_jsonl_streaming(
112
+ result: pyfltr.command.CommandResult,
113
+ config: pyfltr.config.Config,
114
+ ) -> None:
115
+ """1コマンド分のdiagnostic行+tool行をstdoutに即時書き出す。
116
+
117
+ ``_write_lock``取得下で書き出し+flushするため、並列実行されるlinters/testers
118
+ から呼ばれてもツール単位のグルーピングが崩れない。
119
+ """
120
+ lines = build_tool_lines(result, config)
121
+ with _write_lock:
122
+ for line in lines:
123
+ sys.stdout.write(line)
124
+ sys.stdout.write("\n")
125
+ sys.stdout.flush()
126
+
127
+
128
+ def write_jsonl_footer(
129
+ results: list[pyfltr.command.CommandResult],
130
+ *,
131
+ exit_code: int,
132
+ warnings: list[dict[str, typing.Any]] | None = None,
133
+ ) -> None:
134
+ """warning行+summary行をstdoutに書き出す。
135
+
136
+ ``results``は``_build_summary_record()``の集計に使用する。
137
+ """
138
+ with _write_lock:
139
+ for warning in warnings or []:
140
+ sys.stdout.write(_dump(_build_warning_record(warning)))
141
+ sys.stdout.write("\n")
142
+ sys.stdout.write(_dump(_build_summary_record(results, exit_code=exit_code)))
143
+ sys.stdout.write("\n")
144
+ sys.stdout.flush()
145
+
146
+
103
147
  def _dump(record: dict[str, typing.Any]) -> str:
104
148
  """JSON 1 行にシリアライズする。ensure_ascii=False + 区切り最短化でトークン効率を稼ぐ。"""
105
149
  return json.dumps(record, ensure_ascii=False, separators=(",", ":"))
@@ -13,6 +13,7 @@ import typing
13
13
  import pyfltr.cli
14
14
  import pyfltr.command
15
15
  import pyfltr.config
16
+ import pyfltr.llm_output
16
17
  import pyfltr.ui
17
18
  import pyfltr.warnings_
18
19
 
@@ -65,7 +66,7 @@ def build_parser() -> argparse.ArgumentParser:
65
66
  "--no-fix",
66
67
  default=False,
67
68
  action="store_true",
68
- help="run / fast サブコマンドで自動付与される fix ステージを抑止します。",
69
+ help="run / fast / run-for-agent サブコマンドで自動付与される fix ステージを抑止します。",
69
70
  )
70
71
  parser.add_argument(
71
72
  "--stream",
@@ -403,6 +404,9 @@ def run_pipeline(
403
404
  # UIの判定
404
405
  use_ui = not args.no_ui and (args.ui or pyfltr.ui.can_use_ui())
405
406
 
407
+ # JSONL stdoutモード: ツール完了時に随時JSONL行を書き出す
408
+ jsonl_stdout = (args.output_format == "jsonl") and (args.output_file is None)
409
+
406
410
  # run
407
411
  include_fix_stage = bool(getattr(args, "include_fix_stage", False))
408
412
  if use_ui:
@@ -411,8 +415,15 @@ def run_pipeline(
411
415
  else:
412
416
  # 非 TUI モード: 既定はバッファリング (最後にまとめて出力)、`--stream` で従来の即時出力。
413
417
  per_command_log = bool(args.stream)
418
+ on_result = (lambda result: pyfltr.llm_output.write_jsonl_streaming(result, config)) if jsonl_stdout else None
414
419
  results = pyfltr.cli.run_commands_with_cli(
415
- commands, args, config, all_files, per_command_log=per_command_log, include_fix_stage=include_fix_stage
420
+ commands,
421
+ args,
422
+ config,
423
+ all_files,
424
+ per_command_log=per_command_log,
425
+ include_fix_stage=include_fix_stage,
426
+ on_result=on_result,
416
427
  )
417
428
  returncode = 0
418
429
  # `--stream` のときは詳細ログは既に出力済み。summary のみ表示する。
@@ -422,15 +433,23 @@ def run_pipeline(
422
433
  if returncode == 0:
423
434
  returncode = calculate_returncode(results, args.exit_zero_even_if_formatted)
424
435
 
425
- pyfltr.cli.render_results(
426
- results,
427
- config,
428
- include_details=include_details,
429
- output_format=args.output_format or "text",
430
- output_file=args.output_file,
431
- exit_code=returncode,
432
- warnings=pyfltr.warnings_.collected_warnings(),
433
- )
436
+ if jsonl_stdout:
437
+ # ストリーミングモード: diagnostic行+tool行は出力済み。footer(warning+summary)のみ書き出す
438
+ pyfltr.llm_output.write_jsonl_footer(
439
+ results,
440
+ exit_code=returncode,
441
+ warnings=pyfltr.warnings_.collected_warnings(),
442
+ )
443
+ else:
444
+ pyfltr.cli.render_results(
445
+ results,
446
+ config,
447
+ include_details=include_details,
448
+ output_format=args.output_format or "text",
449
+ output_file=args.output_file,
450
+ exit_code=returncode,
451
+ warnings=pyfltr.warnings_.collected_warnings(),
452
+ )
434
453
  return returncode
435
454
 
436
455
 
@@ -437,26 +437,82 @@ def test_parse_typos_jsonl_fallback() -> None:
437
437
  assert errors[0].line == 3
438
438
 
439
439
 
440
- def test_parse_pytest_tb_line() -> None:
441
- """pytest --tb=line 出力からの行番号取得。"""
440
+ def test_parse_pytest_tb_short_project_frame() -> None:
441
+ """pytest --tb=short: プロジェクト内フレームが選択される。"""
442
442
  output = (
443
- "============================= test session starts ==============================\n"
444
- "collected 3 items\n"
445
- "\n"
446
- "tests/foo_test.py F.. [100%]\n"
447
- "\n"
448
443
  "================================= FAILURES =================================\n"
449
- "/abs/path/tests/foo_test.py:42: assert 1 == 2\n"
444
+ "_______________________________ test_bar ________________________________\n"
445
+ "tests/foo_test.py:42: in test_bar\n"
446
+ " result = do_something()\n"
447
+ "E AssertionError: assert 1 == 2\n"
450
448
  "========================= short test summary info ==========================\n"
451
- "FAILED tests/foo_test.py::test_bar - assert 1 == 2\n"
452
- "========================= 1 failed, 2 passed in 0.5s =========================\n"
449
+ "FAILED tests/foo_test.py::test_bar - AssertionError: assert 1 == 2\n"
453
450
  )
454
451
  errors = pyfltr.error_parser.parse_errors("pytest", output)
455
452
  assert len(errors) == 1
453
+ assert errors[0].file == "tests/foo_test.py"
456
454
  assert errors[0].line == 42
457
455
  assert "assert 1 == 2" in errors[0].message
458
456
 
459
457
 
458
+ def test_parse_pytest_tb_short_library_exception() -> None:
459
+ """pytest --tb=short: ライブラリ内部で例外が発生した場合、テスト関数フレームが選択される。"""
460
+ output = (
461
+ "================================= FAILURES =================================\n"
462
+ "_______________________________ test_request ________________________________\n"
463
+ "tests/api_test.py:15: in test_request\n"
464
+ " client.get('/api')\n"
465
+ ".venv/lib/python3.14/site-packages/httpx/_transports/default.py:118: in handle_request\n"
466
+ " resp = self._pool.handle_request(request)\n"
467
+ "E httpx.ConnectError: connection refused\n"
468
+ "========================= short test summary info ==========================\n"
469
+ )
470
+ errors = pyfltr.error_parser.parse_errors("pytest", output)
471
+ assert len(errors) == 1
472
+ assert errors[0].file == "tests/api_test.py"
473
+ assert errors[0].line == 15
474
+ assert "httpx.ConnectError" in errors[0].message
475
+
476
+
477
+ def test_parse_pytest_tb_short_stdlib_exception() -> None:
478
+ """pytest --tb=short: 標準ライブラリで例外が発生した場合、プロジェクト内フレームが選択される。
479
+
480
+ uv管理Pythonでは標準ライブラリが``..``始まりの相対パスで出力される。
481
+ """
482
+ output = (
483
+ "================================= FAILURES =================================\n"
484
+ "_______________________________ test_path ________________________________\n"
485
+ "tests/path_test.py:10: in test_path\n"
486
+ " pathlib.Path('/nonexistent').resolve(strict=True)\n"
487
+ "../.local/share/uv/python/cpython-3.14.0-linux-x86_64/lib/python3.14/pathlib.py:881: in resolve\n"
488
+ " s = os.path.realpath(self, strict=strict)\n"
489
+ "E FileNotFoundError: [Errno 2] No such file or directory: '/nonexistent'\n"
490
+ "========================= short test summary info ==========================\n"
491
+ )
492
+ errors = pyfltr.error_parser.parse_errors("pytest", output)
493
+ assert len(errors) == 1
494
+ assert errors[0].file == "tests/path_test.py"
495
+ assert errors[0].line == 10
496
+
497
+
498
+ def test_parse_pytest_tb_short_all_external() -> None:
499
+ """pytest --tb=short: 全フレームがプロジェクト外の場合、最後のフレームにフォールバック。"""
500
+ output = (
501
+ "================================= FAILURES =================================\n"
502
+ "_______________________________ test_ext ________________________________\n"
503
+ ".venv/lib/python3.14/site-packages/somelib/core.py:50: in setup\n"
504
+ " do_init()\n"
505
+ ".venv/lib/python3.14/site-packages/somelib/init.py:20: in do_init\n"
506
+ " raise RuntimeError('fail')\n"
507
+ "E RuntimeError: fail\n"
508
+ "========================= short test summary info ==========================\n"
509
+ )
510
+ errors = pyfltr.error_parser.parse_errors("pytest", output)
511
+ assert len(errors) == 1
512
+ assert errors[0].line == 20
513
+ assert "RuntimeError: fail" in errors[0].message
514
+
515
+
460
516
  def test_parse_pytest_fallback() -> None:
461
517
  """pytest: --tb=line 形式がなければ FAILED 行にフォールバック(line=0)。"""
462
518
  output = (
@@ -98,7 +98,7 @@ def test_build_lines_unsupported_tool_only(default_config):
98
98
 
99
99
 
100
100
  def test_build_lines_mixed_order(default_config):
101
- """diagnostic はファイル/行順、toolconfig.command_names 順になること。"""
101
+ """ツール単位でdiagnostic+toolがグルーピングされ、config.command_names順に並ぶこと。"""
102
102
  mypy_result = _make_result(
103
103
  "mypy",
104
104
  returncode=1,
@@ -118,11 +118,22 @@ def test_build_lines_mixed_order(default_config):
118
118
  lines = pyfltr.llm_output.build_lines([mypy_result, pylint_result, black_result], default_config, exit_code=1)
119
119
  parsed = [json.loads(line) for line in lines]
120
120
 
121
- diagnostic_records = [r for r in parsed if r["kind"] == "diagnostic"]
122
- assert [(r["file"], r["line"], r["tool"]) for r in diagnostic_records] == [
123
- ("src/a.py", 10, "pylint"),
124
- ("src/a.py", 30, "mypy"),
125
- ("src/b.py", 5, "mypy"),
121
+ # ツール単位のグルーピング: black(tool) mypy(diagnostic, diagnostic, tool) pylint(diagnostic, tool) → summary
122
+ assert [r["kind"] for r in parsed] == [
123
+ "tool", # black
124
+ "diagnostic",
125
+ "diagnostic",
126
+ "tool", # mypy
127
+ "diagnostic",
128
+ "tool", # pylint
129
+ "summary",
130
+ ]
131
+
132
+ # mypy 内の diagnostic はファイル/行順
133
+ mypy_diagnostics = [r for r in parsed if r["kind"] == "diagnostic" and r["tool"] == "mypy"]
134
+ assert [(r["file"], r["line"]) for r in mypy_diagnostics] == [
135
+ ("src/a.py", 30),
136
+ ("src/b.py", 5),
126
137
  ]
127
138
 
128
139
  tool_records = [r for r in parsed if r["kind"] == "tool"]
@@ -364,3 +375,95 @@ def test_run_cli_jsonl_restores_logger_state(mocker, caplog, capsys):
364
375
  pyfltr.main.run(["ci", "--commands=mypy", str(pathlib.Path(__file__).parent.parent)])
365
376
 
366
377
  assert "summary" in caplog.text
378
+
379
+
380
+ # ---------------------------------------------------------------------------
381
+ # build_tool_lines のユニットテスト
382
+ # ---------------------------------------------------------------------------
383
+
384
+
385
+ def test_build_tool_lines_with_diagnostics(default_config):
386
+ """diagnostic行+tool行がツール単位でまとまること。"""
387
+ errors = [
388
+ _make_error("mypy", "src/b.py", 5, "later"),
389
+ _make_error("mypy", "src/a.py", 10, "earlier"),
390
+ ]
391
+ result = _make_result("mypy", returncode=1, errors=errors)
392
+ lines = pyfltr.llm_output.build_tool_lines(result, default_config)
393
+ parsed = [json.loads(line) for line in lines]
394
+
395
+ assert len(parsed) == 3
396
+ # diagnostic行はツール内でファイル/行順にソートされる
397
+ assert parsed[0]["kind"] == "diagnostic"
398
+ assert parsed[0]["file"] == "src/a.py"
399
+ assert parsed[1]["kind"] == "diagnostic"
400
+ assert parsed[1]["file"] == "src/b.py"
401
+ # 最後にtool行
402
+ assert parsed[2]["kind"] == "tool"
403
+ assert parsed[2]["diagnostics"] == 2
404
+
405
+
406
+ def test_build_tool_lines_no_diagnostics(default_config):
407
+ """diagnosticがないツールはtool行のみ。"""
408
+ result = _make_result("black", returncode=0, command_type="formatter")
409
+ lines = pyfltr.llm_output.build_tool_lines(result, default_config)
410
+ parsed = [json.loads(line) for line in lines]
411
+
412
+ assert len(parsed) == 1
413
+ assert parsed[0]["kind"] == "tool"
414
+ assert parsed[0]["diagnostics"] == 0
415
+
416
+
417
+ # ---------------------------------------------------------------------------
418
+ # write_jsonl_streaming のユニットテスト
419
+ # ---------------------------------------------------------------------------
420
+
421
+
422
+ def test_write_jsonl_streaming(default_config, capsys):
423
+ """ストリーミング書き出しがstdoutに即時出力されること。"""
424
+ errors = [_make_error("mypy", "src/a.py", 10, "bad type")]
425
+ result = _make_result("mypy", returncode=1, errors=errors)
426
+ pyfltr.llm_output.write_jsonl_streaming(result, default_config)
427
+
428
+ captured = capsys.readouterr()
429
+ assert captured.err == ""
430
+ parsed = [json.loads(line) for line in captured.out.splitlines()]
431
+ assert len(parsed) == 2
432
+ assert parsed[0]["kind"] == "diagnostic"
433
+ assert parsed[1]["kind"] == "tool"
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # write_jsonl_footer のユニットテスト
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ def test_write_jsonl_footer_with_warnings(capsys):
442
+ """warning行+summary行がstdoutに出力されること。"""
443
+ result = _make_result("mypy", returncode=1, errors=[_make_error("mypy", "a.py", 1, "bad")])
444
+ warnings = [{"source": "config", "message": "test warning"}]
445
+ pyfltr.llm_output.write_jsonl_footer(
446
+ [result],
447
+ exit_code=1,
448
+ warnings=warnings,
449
+ )
450
+
451
+ captured = capsys.readouterr()
452
+ parsed = [json.loads(line) for line in captured.out.splitlines()]
453
+ assert len(parsed) == 2
454
+ assert parsed[0]["kind"] == "warning"
455
+ assert parsed[0]["msg"] == "test warning"
456
+ assert parsed[1]["kind"] == "summary"
457
+ assert parsed[1]["exit"] == 1
458
+
459
+
460
+ def test_write_jsonl_footer_no_warnings(capsys):
461
+ """warningがない場合はsummary行のみ。"""
462
+ result = _make_result("mypy", returncode=0)
463
+ pyfltr.llm_output.write_jsonl_footer([result], exit_code=0)
464
+
465
+ captured = capsys.readouterr()
466
+ parsed = [json.loads(line) for line in captured.out.splitlines()]
467
+ assert len(parsed) == 1
468
+ assert parsed[0]["kind"] == "summary"
469
+ assert parsed[0]["succeeded"] == 1
@@ -1,13 +0,0 @@
1
- {
2
- "extraKnownMarketplaces": {
3
- "ak110-dotfiles": {
4
- "source": {
5
- "source": "github",
6
- "repo": "ak110/dotfiles"
7
- }
8
- }
9
- },
10
- "enabledPlugins": {
11
- "agent-toolkit@ak110-dotfiles": true
12
- }
13
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes