topmark 0.1.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.
Files changed (52) hide show
  1. topmark/__init__.py +14 -0
  2. topmark/cli/__init__.py +23 -0
  3. topmark/cli/cli_types.py +299 -0
  4. topmark/cli/commands/__init__.py +11 -0
  5. topmark/cli/commands/default.py +295 -0
  6. topmark/cli/commands/dump_config.py +108 -0
  7. topmark/cli/commands/filetypes.py +39 -0
  8. topmark/cli/commands/init_config.py +60 -0
  9. topmark/cli/commands/show_defaults.py +54 -0
  10. topmark/cli/commands/version.py +32 -0
  11. topmark/cli/config_resolver.py +82 -0
  12. topmark/cli/main.py +464 -0
  13. topmark/cli/options.py +276 -0
  14. topmark/cli/utils.py +245 -0
  15. topmark/config/__init__.py +520 -0
  16. topmark/config/logging.py +148 -0
  17. topmark/constants.py +24 -0
  18. topmark/file_resolver.py +228 -0
  19. topmark/filetypes/__init__.py +16 -0
  20. topmark/filetypes/base.py +81 -0
  21. topmark/filetypes/instances.py +327 -0
  22. topmark/filetypes/policy.py +44 -0
  23. topmark/filetypes/registry.py +79 -0
  24. topmark/pipeline/__init__.py +25 -0
  25. topmark/pipeline/context.py +363 -0
  26. topmark/pipeline/contracts.py +22 -0
  27. topmark/pipeline/pipelines.py +93 -0
  28. topmark/pipeline/processors/__init__.py +96 -0
  29. topmark/pipeline/processors/base.py +754 -0
  30. topmark/pipeline/processors/pound.py +154 -0
  31. topmark/pipeline/processors/xml.py +278 -0
  32. topmark/pipeline/runner.py +49 -0
  33. topmark/pipeline/steps/__init__.py +11 -0
  34. topmark/pipeline/steps/builder.py +135 -0
  35. topmark/pipeline/steps/comparer.py +73 -0
  36. topmark/pipeline/steps/patcher.py +171 -0
  37. topmark/pipeline/steps/reader.py +173 -0
  38. topmark/pipeline/steps/renderer.py +64 -0
  39. topmark/pipeline/steps/resolver.py +103 -0
  40. topmark/pipeline/steps/scanner.py +146 -0
  41. topmark/pipeline/steps/updater.py +128 -0
  42. topmark/py.typed +1 -0
  43. topmark/rendering/api.py +80 -0
  44. topmark/rendering/formats.py +26 -0
  45. topmark/utils/__init__.py +11 -0
  46. topmark/utils/file.py +42 -0
  47. topmark-0.1.0.dist-info/METADATA +308 -0
  48. topmark-0.1.0.dist-info/RECORD +52 -0
  49. topmark-0.1.0.dist-info/WHEEL +5 -0
  50. topmark-0.1.0.dist-info/entry_points.txt +2 -0
  51. topmark-0.1.0.dist-info/licenses/LICENSE +21 -0
  52. topmark-0.1.0.dist-info/top_level.txt +1 -0
topmark/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ # topmark:header:start
2
+ #
3
+ # file : __init__.py
4
+ # file_relpath : src/topmark/__init__.py
5
+ # project : TopMark
6
+ # license : MIT
7
+ # copyright : (c) 2025 Olivier Biot
8
+ #
9
+ # topmark:header:end
10
+
11
+ """TopMark package.
12
+
13
+ Provides tools and utilities for evaluating and benchmarking models and algorithms.
14
+ """
@@ -0,0 +1,23 @@
1
+ # topmark:header:start
2
+ #
3
+ # file : __init__.py
4
+ # file_relpath : src/topmark/cli/__init__.py
5
+ # project : TopMark
6
+ # license : MIT
7
+ # copyright : (c) 2025 Olivier Biot
8
+ #
9
+ # topmark:header:end
10
+
11
+ """TopMark CLI package.
12
+
13
+ This package groups all Click command definitions and supporting utilities
14
+ for the TopMark command-line interface.
15
+
16
+ Typical usage:
17
+ The console script entry point is defined in ``pyproject.toml`` as::
18
+
19
+ [project.scripts]
20
+ topmark = "topmark.cli.main:cli"
21
+
22
+ All subcommands live in :mod:`topmark.cli.commands`.
23
+ """
@@ -0,0 +1,299 @@
1
+ # topmark:header:start
2
+ #
3
+ # file : cli_types.py
4
+ # file_relpath : src/topmark/cli/cli_types.py
5
+ # project : TopMark
6
+ # license : MIT
7
+ # copyright : (c) 2025 Olivier Biot
8
+ #
9
+ # topmark:header:end
10
+
11
+ """Shared CLI parameter types and argument parsing helpers for TopMark.
12
+
13
+ This module serves as the central container for shared CLI argument types, validators,
14
+ and helpers in TopMark. It defines the ArgsNamespace TypedDict for consistent
15
+ argument passing throughout the CLI, as well as reusable validator functions and
16
+ custom Click parameter types for argument parsing (such as file paths and globs).
17
+ These utilities ensure robust and uniform handling of CLI parameters
18
+ across all TopMark commands and subcommands.
19
+ """
20
+
21
+ import glob
22
+ import logging
23
+ from enum import Enum
24
+ from pathlib import Path
25
+ from typing import (
26
+ TYPE_CHECKING,
27
+ Any,
28
+ Generic,
29
+ Iterable,
30
+ NoReturn,
31
+ Protocol,
32
+ TypedDict,
33
+ TypeVar,
34
+ cast,
35
+ )
36
+
37
+ import click
38
+
39
+ from topmark.rendering.formats import HeaderOutputFormat
40
+
41
+ if TYPE_CHECKING:
42
+ # Type-only import so we can reference the symbol in annotations
43
+ from click.shell_completion import CompletionItem as ClickCompletionItem
44
+
45
+ class ParamTypeBase(Protocol):
46
+ """Typed base to avoid subclassing Any when Click lacks stubs."""
47
+
48
+ name: str
49
+
50
+ else:
51
+ # At runtime, subclass the real Click type
52
+ ParamTypeBase = click.ParamType # type: ignore[assignment]
53
+
54
+ # Type variable bounded to Enum for generic EnumParam
55
+ E = TypeVar("E", bound=Enum)
56
+
57
+
58
+ class ArgsNamespace(TypedDict, total=False):
59
+ """Namespace for parsed CLI arguments and options in TopMark.
60
+
61
+ This TypedDict is used to represent all relevant CLI arguments and options
62
+ passed through TopMark commands and subcommands. It facilitates type-safe
63
+ access and transfer of parsed CLI state, including global options, config
64
+ file overrides, file selection, filtering, and formatting flags.
65
+
66
+ Attributes:
67
+ log_level: Verbosity level for logging.
68
+ no_config: Whether to ignore local config files.
69
+ config_files: List of extra config file paths.
70
+ files: List of file paths to process.
71
+ stdin: Whether to read file paths from stdin.
72
+ include_patterns: Glob patterns of files to include.
73
+ include_from: Files containing include patterns.
74
+ exclude_patterns: Glob patterns of files to exclude.
75
+ exclude_from: Files containing exclude patterns.
76
+ file_types: Restrict to given file types.
77
+ relative_to: Root directory for relative paths.
78
+ align_fields: Align header fields with colons.
79
+ header_format: Header output format (file type aware, plain, or json).
80
+ """
81
+
82
+ # Global options: retrieve from ctx.obj
83
+ log_level: int
84
+
85
+ # TopMark Command
86
+ # command: TopMarkCommand
87
+
88
+ # Command options: config
89
+ no_config: bool | None
90
+ config_files: list[str] | None
91
+
92
+ # Command arguments
93
+ files: list[str]
94
+
95
+ # Command options: common_file_and_filtering_options
96
+ stdin: bool | None
97
+ include_patterns: list[str] | None
98
+ include_from: list[str] | None
99
+ exclude_patterns: list[str] | None
100
+ exclude_from: list[str] | None
101
+ file_types: list[str] | None
102
+ relative_to: str | None
103
+
104
+ # Command options: formatting
105
+ align_fields: bool | None
106
+ header_format: HeaderOutputFormat | None
107
+
108
+
109
+ def build_args_namespace(
110
+ *,
111
+ log_level: int = logging.WARNING,
112
+ # command: TopMarkCommand = TopMarkCommand.NONE,
113
+ no_config: bool | None = None,
114
+ config_files: list[str] | None = None,
115
+ files: list[str] | None = None,
116
+ stdin: bool | None = None,
117
+ include_patterns: list[str] | None = None,
118
+ include_from: list[str] | None = None,
119
+ exclude_patterns: list[str] | None = None,
120
+ exclude_from: list[str] | None = None,
121
+ file_types: list[str] | None = None,
122
+ relative_to: str | None = None,
123
+ align_fields: bool | None = None,
124
+ header_format: HeaderOutputFormat | None = None,
125
+ ) -> ArgsNamespace:
126
+ """Build an ArgsNamespace dictionary for CLI argument passing.
127
+
128
+ Args:
129
+ log_level: Verbosity level for logging.
130
+ no_config: Whether to ignore local config files.
131
+ config_files: List of extra config file paths.
132
+ files: List of file paths to process.
133
+ stdin: Whether to read file paths from stdin.
134
+ include_patterns: Glob patterns of files to include.
135
+ include_from: Files containing include patterns.
136
+ exclude_patterns: Glob patterns of files to exclude.
137
+ exclude_from: Files containing exclude patterns.
138
+ file_types: Restrict to given file types.
139
+ relative_to: Root directory for relative paths.
140
+ align_fields: Align header fields with colons.
141
+ header_format: Header output format (native, plain, or json).
142
+
143
+ Returns:
144
+ Dictionary of CLI argument values for use throughout the CLI.
145
+ """
146
+ return {
147
+ "log_level": log_level,
148
+ # "command": command,
149
+ "no_config": no_config,
150
+ "config_files": config_files,
151
+ "files": files if files is not None else [],
152
+ "stdin": stdin,
153
+ "include_patterns": include_patterns,
154
+ "include_from": include_from,
155
+ "exclude_patterns": exclude_patterns,
156
+ "exclude_from": exclude_from,
157
+ "file_types": file_types,
158
+ "relative_to": relative_to,
159
+ "align_fields": align_fields,
160
+ "header_format": header_format,
161
+ }
162
+
163
+
164
+ # --- Example custom Click parameter types and validators for TopMark CLI ---
165
+
166
+ # HeaderFormatParam: Click callback for validating header format option
167
+
168
+
169
+ class EnumParam(ParamTypeBase, Generic[E]):
170
+ """A Click parameter type that converts a string to a member of a given Enum."""
171
+
172
+ # Add instance variable annotations for maximum clarity
173
+ enum_cls: type[E]
174
+ name: str
175
+ choices: list[str]
176
+
177
+ def __init__(self, enum_cls: type[E]):
178
+ """Initialize the EnumParam instance."""
179
+ self.enum_cls = enum_cls
180
+ self.name = self.enum_cls.__name__.lower()
181
+ # Assume the enum exposes string-valued members (e.g., HeaderOutputFormat)
182
+ self.choices = [cast(str, getattr(e, "value", str(e))) for e in self.enum_cls]
183
+
184
+ def _fail_noreturn(
185
+ self,
186
+ message: str,
187
+ param: click.Parameter | None,
188
+ ctx: click.Context | None,
189
+ ) -> NoReturn:
190
+ """Raise a BadParameter with a NoReturn signature (clear to type checkers)."""
191
+ raise click.BadParameter(message, param=param, ctx=ctx)
192
+
193
+ def convert(
194
+ self,
195
+ value: str | None,
196
+ param: click.Parameter | None,
197
+ ctx: click.Context | None,
198
+ ) -> E | None:
199
+ """Converts a string to a member of the Enum."""
200
+ if value is None:
201
+ return None
202
+
203
+ # Case-insensitive lookup by the enum's string value
204
+ lookup: dict[str, E] = {
205
+ cast(str, getattr(choice, "value", str(choice))).lower(): choice
206
+ for choice in cast(Iterable[E], self.enum_cls)
207
+ }
208
+
209
+ key = value.lower()
210
+ if key in lookup:
211
+ return lookup[key]
212
+
213
+ # Raise a BadParameter exception for invalid input
214
+ self._fail_noreturn(
215
+ f"Invalid value '{value}'. Must be one of: {', '.join(self.choices)}",
216
+ param,
217
+ ctx,
218
+ )
219
+
220
+ def shell_complete(
221
+ self,
222
+ ctx: click.Context, # pylint: disable=unused-argument
223
+ param: click.Parameter, # pylint: disable=unused-argument
224
+ incomplete: str,
225
+ ) -> list["ClickCompletionItem"]:
226
+ """Tab completion for Click.
227
+
228
+ Bash: `eval "$(_TOPMARK_COMPLETE=bash_source topmark)"`
229
+ Zsh: `eval "$(_TOPMARK_COMPLETE=zsh_source topmark)"`
230
+ """
231
+ # Runtime import to avoid import-time dependency for non-completion paths
232
+ from click.shell_completion import (
233
+ CompletionItem as RuntimeCompletionItem,
234
+ ) # Click 8.x
235
+
236
+ prefix = (incomplete or "").lower()
237
+ items: list["ClickCompletionItem"] = []
238
+ for e in cast(Iterable[E], self.enum_cls):
239
+ val = str(getattr(e, "value", e))
240
+ if val.lower().startswith(prefix):
241
+ items.append(RuntimeCompletionItem(val))
242
+ return items
243
+
244
+ def __repr__(self) -> str:
245
+ """Return a string representation."""
246
+ return f"EnumParam({self.enum_cls.__name__})"
247
+
248
+
249
+ def FileTypeParam(
250
+ ctx: click.Context, # pylint: disable=unused-argument
251
+ param: click.Parameter, # pylint: disable=unused-argument
252
+ value: Any,
253
+ ) -> Path | None:
254
+ """Validator: Ensure a CLI argument is a valid file path.
255
+
256
+ Validates and converts the CLI argument to a Path, raising an error if it does not exist
257
+ or is not a file.
258
+
259
+ Args:
260
+ ctx: Click context.
261
+ param: The Click parameter.
262
+ value: The CLI argument value to validate.
263
+
264
+ Returns:
265
+ The validated file path, or None if value is None.
266
+
267
+ Raises:
268
+ click.BadParameter: If the file does not exist or is not a file.
269
+ """
270
+ if value is None:
271
+ return None
272
+ path = Path(value)
273
+ # Check if the path exists and is a file.
274
+ if not path.exists() or not path.is_file():
275
+ raise click.BadParameter(f"File not found or not a file: {value}")
276
+ return path
277
+
278
+
279
+ def GlobParam(
280
+ ctx: click.Context, # pylint: disable=unused-argument
281
+ param: click.Parameter, # pylint: disable=unused-argument
282
+ value: Any,
283
+ ) -> list[Path]:
284
+ """Validator: Expand a glob pattern CLI argument to a list of file paths.
285
+
286
+ Expands the given glob pattern string into a list of Path objects matching the pattern.
287
+
288
+ Args:
289
+ ctx: Click context.
290
+ param: The Click parameter.
291
+ value: The glob pattern string.
292
+
293
+ Returns:
294
+ List of Path objects matching the glob pattern, or an empty list if value is None.
295
+ """
296
+ if value is None:
297
+ return []
298
+ # Use glob.glob for shell-style wildcards; return as Path objects.
299
+ return [Path(p) for p in glob.glob(value, recursive=True)]
@@ -0,0 +1,11 @@
1
+ # topmark:header:start
2
+ #
3
+ # file : __init__.py
4
+ # file_relpath : src/topmark/cli/commands/__init__.py
5
+ # project : TopMark
6
+ # license : MIT
7
+ # copyright : (c) 2025 Olivier Biot
8
+ #
9
+ # topmark:header:end
10
+
11
+ """TopMark CLI commands."""
@@ -0,0 +1,295 @@
1
+ # topmark:header:start
2
+ #
3
+ # file : default.py
4
+ # file_relpath : src/topmark/cli/commands/default.py
5
+ # project : TopMark
6
+ # license : MIT
7
+ # copyright : (c) 2025 Olivier Biot
8
+ #
9
+ # topmark:header:end
10
+
11
+ """Default TopMark operation (check/apply).
12
+
13
+ This module implements the unified default behavior of TopMark’s CLI. It is
14
+ invoked when no explicit subcommand is provided and processes the file paths
15
+ captured by Click (``ctx.args``), performing a dry‑run **check** by default or
16
+ **applying** changes when ``--apply`` is given.
17
+
18
+ Examples:
19
+ Check files and print a human summary::
20
+
21
+ topmark --summary src
22
+
23
+ Emit per‑file objects in NDJSON (one per line)::
24
+
25
+ topmark --format=ndjson src pkg
26
+
27
+ Write changes and show diffs (human output only)::
28
+
29
+ topmark --apply --diff .
30
+ """
31
+
32
+ import click
33
+ from yachalk import chalk
34
+
35
+ from topmark.cli.cli_types import EnumParam
36
+ from topmark.cli.config_resolver import resolve_config_from_click
37
+ from topmark.cli.options import (
38
+ common_config_options,
39
+ common_file_and_filtering_options,
40
+ common_header_formatting_options,
41
+ typed_option,
42
+ underscored_trap_option,
43
+ )
44
+ from topmark.cli.utils import (
45
+ OutputFormat,
46
+ count_by_outcome,
47
+ )
48
+ from topmark.config.logging import get_logger
49
+ from topmark.file_resolver import resolve_file_list
50
+ from topmark.pipeline import runner
51
+ from topmark.pipeline.context import ComparisonStatus, ProcessingContext
52
+ from topmark.pipeline.pipelines import get_pipeline
53
+ from topmark.rendering.formats import HeaderOutputFormat
54
+
55
+ logger = get_logger(__name__)
56
+
57
+
58
+ @common_config_options
59
+ @common_file_and_filtering_options
60
+ @common_header_formatting_options
61
+ @typed_option(
62
+ "--apply", "apply_changes", is_flag=True, help="Write changes to files (off by default)."
63
+ )
64
+ @typed_option("--diff", is_flag=True, help="Show unified diffs (human output only).")
65
+ @typed_option(
66
+ "--summary",
67
+ "summary_mode",
68
+ is_flag=True,
69
+ help="Show outcome counts instead of per-file details.",
70
+ )
71
+ @typed_option(
72
+ "--skip-compliant",
73
+ "skip_compliant",
74
+ is_flag=True,
75
+ help="Hide files that are already up-to-date.",
76
+ )
77
+ @underscored_trap_option("--skip_compliant")
78
+ @typed_option(
79
+ "--format",
80
+ "output_format",
81
+ type=EnumParam(OutputFormat),
82
+ default=None,
83
+ help=f"Output format ({', '.join(v.value for v in OutputFormat)}).",
84
+ )
85
+ def default_command(
86
+ stdin: bool,
87
+ include_patterns: list[str],
88
+ include_from: list[str],
89
+ exclude_patterns: list[str],
90
+ exclude_from: list[str],
91
+ no_config: bool,
92
+ config_paths: list[str],
93
+ file_types: list[str],
94
+ relative_to: str | None,
95
+ align_fields: bool,
96
+ header_format: HeaderOutputFormat | None,
97
+ apply_changes: bool,
98
+ diff: bool,
99
+ summary_mode: bool,
100
+ skip_compliant: bool,
101
+ output_format: OutputFormat | None,
102
+ ) -> None:
103
+ """Run the unified default command (check/apply).
104
+
105
+ When invoked via the root group, this command receives options parsed at the
106
+ group level and reads positional paths from ``click.get_current_context().args``.
107
+
108
+ Args:
109
+ stdin: If True, read file paths from standard input (one per line).
110
+ include_patterns: Glob patterns specifying additional files or directories to include.
111
+ include_from: Paths to files that contain include glob patterns (one per line).
112
+ exclude_patterns: Glob patterns specifying files or directories to exclude.
113
+ exclude_from: Paths to files that contain exclude glob patterns (one per line).
114
+ no_config: If True, ignore user/project configuration files and use defaults only.
115
+ config_paths: Additional configuration file paths to load and merge.
116
+ file_types: Restrict processing to the given TopMark file type identifiers.
117
+ relative_to: Base directory used to compute relative paths in outputs.
118
+ align_fields: If True, align header fields by the colon for readability.
119
+ header_format: Optional override for the header output format used when rendering.
120
+ apply_changes: If True, write changes to files; otherwise perform a dry run.
121
+ diff: If True, show unified diffs of header changes (human output only).
122
+ summary_mode: If True, print outcome counts instead of per-file details.
123
+ skip_compliant: If True, suppress files whose comparison status is UNCHANGED.
124
+ output_format: Output format to use (default, json, or ndjson).
125
+
126
+ Raises:
127
+ click.UsageError: If no input is provided (Black‑style preflight).
128
+ SystemExit: With code 2 in check mode when changes would be required.
129
+
130
+ Returns:
131
+ None
132
+
133
+ Examples:
134
+ Dry‑run (default)::
135
+
136
+ topmark src
137
+
138
+ Apply changes::
139
+
140
+ topmark --apply src
141
+
142
+ NDJSON per‑file output::
143
+
144
+ topmark --format=ndjson src
145
+ """
146
+ ctx = click.get_current_context()
147
+ files = list(ctx.args)
148
+
149
+ # Before resolving config/files — block empty invocations (Black-style)
150
+ no_inputs = (
151
+ not files and not stdin and not include_patterns and not include_from and not config_paths
152
+ )
153
+ if no_inputs:
154
+ # Show a friendly usage error with guidance
155
+ raise click.UsageError(
156
+ "No input specified. Provide one or more paths, or use --stdin. "
157
+ "See --help for examples.",
158
+ ctx=ctx,
159
+ )
160
+
161
+ # === Primary TopMark operation (default: check, write with --apply) ===
162
+ config = resolve_config_from_click(
163
+ ctx=ctx,
164
+ files=list(files),
165
+ stdin=stdin,
166
+ include_patterns=list(include_patterns),
167
+ include_from=list(include_from),
168
+ exclude_patterns=list(exclude_patterns),
169
+ exclude_from=list(exclude_from),
170
+ file_types=list(file_types),
171
+ relative_to=relative_to,
172
+ no_config=no_config,
173
+ config_paths=list(config_paths),
174
+ align_fields=align_fields,
175
+ header_format=header_format,
176
+ )
177
+
178
+ logger.trace("Config after merging args: %s", config)
179
+
180
+ # file_list = resolve_file_list(config)
181
+ # Use Click's text stream for stdin so CliRunner/invoke input is read reliably
182
+ stdin_stream = click.get_text_stream("stdin") if stdin else None
183
+ file_list = resolve_file_list(config, stdin_stream=stdin_stream)
184
+ if not file_list:
185
+ click.echo(chalk.blue("\nNo files.\n"))
186
+ raise SystemExit(0)
187
+
188
+ click.echo(chalk.blue(f"\nProcessing {len(file_list)} file(s):\n"))
189
+ click.echo(chalk.bold.underline("TopMark Results:"))
190
+
191
+ # Use summary pipeline when --summary is set, else full (compare+update+patch)
192
+ pipeline_name = "summary" if summary_mode else "apply"
193
+ steps = get_pipeline(pipeline_name)
194
+
195
+ results: list[ProcessingContext] = []
196
+ for path in file_list:
197
+ try:
198
+ ctx_obj: ProcessingContext = ProcessingContext.bootstrap(path=path, config=config)
199
+ ctx_obj = runner.run(ctx_obj, steps)
200
+ results.append(ctx_obj)
201
+ except (FileNotFoundError, PermissionError, IsADirectoryError) as e:
202
+ logger.error("Filesystem error while processing %s: %s", path, e)
203
+ click.echo(chalk.red(f"Filesystem error processing {path}: {e}"))
204
+ continue
205
+ except UnicodeDecodeError as e:
206
+ logger.error("Encoding error while reading %s: %s", path, e)
207
+ click.echo(chalk.red(f"Encoding error in {path}: {e}"))
208
+ continue
209
+ except Exception as e: # pragma: no cover - unexpected path
210
+ logger.exception("Unexpected error processing %s", path)
211
+ click.echo(
212
+ chalk.red(f"Unexpected error processing {path}: {e} (use -vv for traceback)")
213
+ )
214
+ continue
215
+
216
+ # Optional filtering
217
+ if skip_compliant:
218
+ view_results = [r for r in results if r.status.comparison is not ComparisonStatus.UNCHANGED]
219
+ else:
220
+ view_results = results
221
+
222
+ fmt: OutputFormat = output_format or OutputFormat.DEFAULT
223
+ # Machine formats first
224
+ if fmt is OutputFormat.NDJSON:
225
+ import json as _json
226
+
227
+ if summary_mode:
228
+ counts = count_by_outcome(view_results)
229
+ for key, (n, label, _color) in counts.items():
230
+ click.echo(_json.dumps({"key": key, "count": n, "label": label}))
231
+ else:
232
+ for r in view_results:
233
+ click.echo(_json.dumps(r.to_dict()))
234
+ elif fmt is OutputFormat.JSON:
235
+ import json as _json
236
+
237
+ if summary_mode:
238
+ counts = count_by_outcome(view_results)
239
+ data = {k: {"count": n, "label": label} for k, (n, label, _color) in counts.items()}
240
+ click.echo(_json.dumps(data, indent=2))
241
+ else:
242
+ payload = [r.to_dict() for r in view_results]
243
+ click.echo(_json.dumps(payload, indent=2))
244
+ else:
245
+ # Human output
246
+ if summary_mode:
247
+ click.echo()
248
+ click.echo(chalk.bold.underline("Summary by outcome:"))
249
+
250
+ counts = count_by_outcome(view_results)
251
+ label_width = max((len(v[1]) for v in counts.values()), default=0) + 1
252
+ num_width = len(str(len(file_list)))
253
+ for _key, (n, label, color) in counts.items():
254
+ click.echo(color(f" {label:<{label_width}}: {n:>{num_width}}"))
255
+
256
+ else:
257
+ for r in view_results:
258
+ click.echo(r.summary)
259
+ if diff:
260
+ for r in view_results:
261
+ if r.header_diff:
262
+ click.echo(r.header_diff)
263
+
264
+ # Writes (only when --apply is set)
265
+ if apply_changes:
266
+ written = 0
267
+ failed = 0
268
+ from pathlib import Path
269
+
270
+ for r in results:
271
+ try:
272
+ if r.updated_file_lines is not None and r.file_lines != r.updated_file_lines:
273
+ Path(str(r.path)).write_text("".join(r.updated_file_lines), encoding="utf-8")
274
+ written += 1
275
+ except Exception as e: # pragma: no cover
276
+ failed += 1
277
+ logger.error("Failed to write %s: %s", r.path, e)
278
+ if fmt is OutputFormat.DEFAULT:
279
+ click.echo(
280
+ f"\nApplied changes to {written} file(s)." if written else "\nNo changes to apply."
281
+ )
282
+ if failed:
283
+ raise SystemExit(1)
284
+
285
+ # Exit code policy: in check mode, non-zero if changes would be needed
286
+ if not apply_changes:
287
+ would_change = any(
288
+ (r.status.header.name == "MISSING") or (r.status.comparison.name == "CHANGED")
289
+ for r in results
290
+ )
291
+ if would_change:
292
+ raise SystemExit(2)
293
+
294
+ click.echo("All done!")
295
+ return