Skip to content

feat(wave-2b): Phase 16 + 19 + 21 + 35 + 36 consolidated integration - #30

Merged
cemililik merged 16 commits into
developmentfrom
closure/wave2b-integration
May 5, 2026
Merged

cemililik merged 16 commits into
developmentfrom
closure/wave2b-integration

Conversation

@cemililik

@cemililik cemililik commented May 4, 2026 •

Copy link
Copy Markdown
Collaborator

Five-phase consolidated integration covering the dependency-free batch unblocked by the Wave 2a merge. All work lives on closure/wave2b-integration as a single reviewable PR.

PHASE 21 — GDPR Article 17 right-to-erasure (forgelm purge)

  • forgelm/cli/subcommands/_purge.py — three-mode dispatcher:
    • --row-id --corpus — atomic JSONL row erasure, hashed target_id (SHA-256(salt + value)), persistent per-output-dir salt at <output_dir>/.forgelm_audit_salt (mode 0600).
    • --run-id --kind {staging,artefacts} — run-scoped artefact erasure (staging dir or compliance bundle).
    • --check-policy — read-only retention-policy violation report (always exits 0; report-not-gate per design §10 Q5).
  • forgelm/config.py — RetentionConfig (4 horizons + enforce mode) + ForgeConfig.retention field. EvaluationConfig.staging_ttl_days deprecation alias-forwards to retention.staging_ttl_days with DeprecationWarning; conflicting values raise ConfigError.
  • 6 new audit events (data.erasure_*) catalogued bilingually.
  • Bilingual operator guide: docs/guides/gdpr_erasure(.md|-tr.md).
  • 28 regression tests covering every design §7 acceptance row.

PHASE 35 — Air-gap pre-cache subcommands

  • forgelm/cli/subcommands/_cache.py:
    • cache-models --model M [--safety S] [--output DIR] — repeatable --model; huggingface_hub.snapshot_download populates HF cache. Resolution: --output > HF_HUB_CACHE > HF_HOME/hub > default.
    • cache-tasks --tasks CSV — lm_eval.tasks.get_task_dict + dataset download_and_prepare; requires [eval] extra (clear install hint on missing import).
  • 6 new cache.populate_* audit events catalogued.
  • 14 regression tests with mocked huggingface_hub + lm_eval (suite stays network-free + extra-free).

PHASE 36 — Compliance verification toolbelt

  • verify-annex-iv — Annex IV §1-9 field check + manifest hash recompute (canonical-JSON SHA-256).
  • safety-eval --model {--probes | --default-probes} — standalone safety eval; supports HF + GGUF (GGUF requires [export]).
  • verify-gguf — magic header + metadata parse + optional SHA-256 sidecar comparison.
  • New bundled probe set: forgelm/safety_prompts/default_probes.jsonl (50 prompts × 14 harm categories).
  • Public library functions exposed: verify_annex_iv_artifact, verify_gguf, with VerifyAnnexIVResult / VerifyGgufResult types.
  • 21 regression tests.

PHASE 16 — Pydantic description= migration with CI guard

  • tools/check_field_descriptions.py — AST-based scanner; --strict mode exits 1 on any field missing description=.
  • .github/workflows/ci.yml — new "Pydantic description= guard" step in lint job runs the scanner in strict mode.
  • All 174 fields across 19 Pydantic config classes migrated to Field(default=..., description=...) form.

PHASE 19 — Library API support (Implementation)

  • forgelm/init.py rewritten as strict lazy-import facade:
    • PEP 562 getattr resolves stable symbols on first access via _LAZY_SYMBOLS registry; results cached in globals() so subsequent accesses are zero-cost.
    • dir lists full public surface for IDE autocomplete + help() discovery before any attribute access.
    • TYPE_CHECKING block for mypy --strict / pyright consumers.
  • forgelm/_version.py — api_version separate from version; anchored at 1.0.0.
  • forgelm/py.typed (PEP 561 marker) shipped via pyproject.toml package-data block.
  • all expanded with 25 stable symbols covering every Phase 19 documented surface (training, data, audit, compliance, Phase 36 verification toolbelt, webhook).
  • 13 integration tests including subprocess-based lazy-import discipline assertions.

VERIFICATION

  • ruff check + format clean
  • pytest 1237 passed, 14 skipped (was 1160 baseline; +77 new)
  • All 5 new subcommands surface in forgelm --help + epilog
  • forgelm --config config_template.yaml --dry-run green

Summary by CodeRabbit

  • New Features

    • Added forgelm purge (row/run erasure + retention reporting), verify-annex-iv, verify-gguf, cache-models, cache-tasks, and safety-eval (bundled ~50-probe seed).
  • Documentation

    • New GDPR right-to-erasure guides (EN/TR), expanded audit-event catalog, updated CLI reference and JSON output schemas, and safety-eval guidance.
  • Chores

    • Library API/versioning polish, shipped type marker, CI guard for Pydantic field descriptions, and broad test coverage for new commands.

Five-phase consolidated integration covering the dependency-free batch
unblocked by the Wave 2a merge.  All work lives on
closure/wave2b-integration as a single reviewable PR.

PHASE 21 — GDPR Article 17 right-to-erasure (`forgelm purge`)

- `forgelm/cli/subcommands/_purge.py` — three-mode dispatcher:
  * --row-id <id> --corpus <path> — atomic JSONL row erasure, hashed
    target_id (SHA-256(salt + value)), persistent per-output-dir salt
    at <output_dir>/.forgelm_audit_salt (mode 0600).
  * --run-id <id> --kind {staging,artefacts} — run-scoped artefact
    erasure (staging dir or compliance bundle).
  * --check-policy — read-only retention-policy violation report
    (always exits 0; report-not-gate per design §10 Q5).
- forgelm/config.py — RetentionConfig (4 horizons + enforce mode) +
  ForgeConfig.retention field.  EvaluationConfig.staging_ttl_days
  deprecation alias-forwards to retention.staging_ttl_days with
  DeprecationWarning; conflicting values raise ConfigError.
- 6 new audit events (data.erasure_*) catalogued bilingually.
- Bilingual operator guide: docs/guides/gdpr_erasure(.md|-tr.md).
- 28 regression tests covering every design §7 acceptance row.

PHASE 35 — Air-gap pre-cache subcommands

- forgelm/cli/subcommands/_cache.py:
  * cache-models --model M [--safety S] [--output DIR] — repeatable
    --model; huggingface_hub.snapshot_download populates HF cache.
    Resolution: --output > HF_HUB_CACHE > HF_HOME/hub > default.
  * cache-tasks --tasks CSV — lm_eval.tasks.get_task_dict + dataset
    download_and_prepare; requires [eval] extra (clear install hint
    on missing import).
- 6 new cache.populate_* audit events catalogued.
- 14 regression tests with mocked huggingface_hub + lm_eval (suite
  stays network-free + extra-free).

PHASE 36 — Compliance verification toolbelt

- verify-annex-iv <path> — Annex IV §1-9 field check + manifest hash
  recompute (canonical-JSON SHA-256).
- safety-eval --model <path> {--probes <jsonl> | --default-probes} —
  standalone safety eval; supports HF + GGUF (GGUF requires [export]).
- verify-gguf <path> — magic header + metadata parse + optional
  SHA-256 sidecar comparison.
- New bundled probe set: forgelm/safety_prompts/default_probes.jsonl
  (50 prompts × 14 harm categories).
- Public library functions exposed: verify_annex_iv_artifact,
  verify_gguf, with VerifyAnnexIVResult / VerifyGgufResult types.
- 21 regression tests.

PHASE 16 — Pydantic description= migration with CI guard

- tools/check_field_descriptions.py — AST-based scanner;
  --strict mode exits 1 on any field missing description=.
- .github/workflows/ci.yml — new "Pydantic description= guard"
  step in lint job runs the scanner in strict mode.
- All 174 fields across 19 Pydantic config classes migrated to
  Field(default=..., description=...) form.

PHASE 19 — Library API support (Implementation)

- forgelm/__init__.py rewritten as strict lazy-import facade:
  * PEP 562 __getattr__ resolves stable symbols on first access via
    _LAZY_SYMBOLS registry; results cached in globals() so
    subsequent accesses are zero-cost.
  * __dir__ lists full public surface for IDE autocomplete +
    help() discovery before any attribute access.
  * TYPE_CHECKING block for mypy --strict / pyright consumers.
- forgelm/_version.py — __api_version__ separate from __version__;
  anchored at 1.0.0.
- forgelm/py.typed (PEP 561 marker) shipped via pyproject.toml
  package-data block.
- __all__ expanded with 25 stable symbols covering every Phase 19
  documented surface (training, data, audit, compliance, Phase 36
  verification toolbelt, webhook).
- 13 integration tests including subprocess-based lazy-import
  discipline assertions.

VERIFICATION

- ruff check + format clean
- pytest 1237 passed, 14 skipped (was 1160 baseline; +77 new)
- All 5 new subcommands surface in `forgelm --help` + epilog
- forgelm --config config_template.yaml --dry-run green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown
ⓘ You've reached your Qodo monthly free-tier limit. Reviews pause until next month — upgrade your plan to continue now, or link your paid account if you already have one.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented May 4, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b8c23ab4-6c64-4990-9447-8b1501372255

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR consolidates five implementation phases (16, 19, 21, 35, 36) of the Wave 2b closure plan. It adds GDPR erasure (forgelm purge), air-gap cache-model/task subcommands, verification toolbelt (Annex IV, GGUF, safety-eval), lazy-import library API with __api_version__, retention-policy config, and a Pydantic field-description CI guard. Extensive bilingual documentation, tests, and tooling support these features.

Changes

Library API Facade & Versioning Foundation

Layer / File(s) Summary
Version Management
forgelm/_version.py
New module defines __version__ (from package metadata) and __api_version__ (1.0.0 public API contract), enabling independent versioning of CLI vs library surface.
Lazy Import Refactoring
forgelm/__init__.py
Replaces hardcoded __getattr__ chain with table-driven lazy resolution via _LAZY_SYMBOLS; caches resolved symbols into globals(). Adds __dir__() for discovery; imports version symbols from _version.py.
Public API Definition
forgelm/__init__.py, forgelm/cli/__init__.py
Expands __all__ to enumerate 20+ stable public symbols (config, training, audit, compliance, verification). CLI facade re-exports subcommand helpers and verifier types.
Type Hint Distribution
pyproject.toml
Adds PEP 561 marker (py.typed) and includes bundled safety probes (forgelm/safety_prompts/*.jsonl) in wheel.
CI Pydantic Guard
.github/workflows/ci.yml, tools/check_field_descriptions.py
New lint step enforces Field(..., description=) completeness for forgelm/config.py via AST-based scanner; exit code 0 only when all fields documented.
Pydantic Guard Tests
tests/test_check_field_descriptions.py
Regression suite for scanner across Field/Annotated patterns, multiple classes, Pydantic v2 compatibility, and strict-mode exit codes.
Library API Integration Tests
tests/test_library_api.py
Validates __all__ completeness, dir() cleanliness, lazy-import discipline (no torch/heavy dependencies on cold import), lazy-resolution caching semantics, and smoke tests for entry points.

Configuration & Compliance Foundations

Layer / File(s) Summary
Retention Config Model
forgelm/config.py
New RetentionConfig model with audit/staging/ephemeral/raw-documents horizons, enforce mode (log_only/warn_on_excess/block_on_excess). Optional ForgeConfig.retention block.
Deprecation Alias-Forward
forgelm/config.py
evaluation.staging_ttl_days reconciliation logic: forwards to retention.staging_ttl_days when only legacy set; emits DeprecationWarning for match/forward; raises ConfigError on conflicting values; uses model_fields_set for detection.
Pydantic Field Descriptions
forgelm/config.py
~30 field declarations updated to use Field(..., description=...) across MoE, multimodal, LoRA, training, governance, compliance, webhook blocks; enforces CI lint.
Annex IV Artifact Builder
forgelm/compliance.py
New build_annex_iv_artifact(manifest) constructs verifier-compatible Annex IV §1-9 canonical JSON; stamped with compute_annex_iv_manifest_hash(...) (SHA-256 over canonical payload minus metadata hash fields).
Webhook Config Persistence
forgelm/compliance.py
generate_training_manifest persists webhook_config block (when config.webhook present) with allow-listed fields only, excluding literal URL.
JSON Output Hardening
forgelm/cli/_result.py, forgelm/cli/subcommands/_approvals.py, forgelm/cli/subcommands/_doctor.py
json.dumps(..., default=str, ensure_ascii=False) applied to result/approvals/doctor envelopes to preserve non-JSON-native values and Unicode characters.

GDPR Article 17 Right-to-Erasure (Phase 21)

Layer / File(s) Summary
Purge Subcommand Wiring
forgelm/cli/_parser.py, forgelm/cli/_dispatch.py
New argparse registrar _add_purge_subcommand with mutually-exclusive mode group (--row-id/--run-id/--check-policy). Dispatcher routes to mode-specific handlers.
Row Erasure Implementation
forgelm/cli/subcommands/_purge.py
_run_purge_row_id(...) scans JSONL for id/row_id matches; validates match count; resolves per-output-dir audit salt; computes target-id hash; atomically rewrites corpus dropping matched line(s) via _atomic_rewrite_dropping_lines; emits audit requested/completed/failed chain.
Run-Scoped Artifact Deletion
forgelm/cli/subcommands/_purge.py
_run_purge_run_id(...) resolves staging or artefacts targets; uses token-boundary run_id matching to prevent prefix-superstring deletes; sums freed bytes; emits audit events.
Retention Policy Scanning
forgelm/cli/subcommands/_purge.py
_run_purge_check_policy(...) strictly loads config, scans artefact horizons (audit, staging, compliance, raw-documents) via _scan_retention_violations, computes per-run/global age from audit log timestamps with mtime fallback; reports violations; always exits 0.
Audit Integration
forgelm/cli/subcommands/_purge.py
Six new audit events (data.erasure_requested/completed/failed, three warning events) emitted via AuditLogger with target_id hash, salt source, and warning-condition detection (memorisation, synthetic data, webhook).
GDPR Erasure Documentation (English)
docs/guides/gdpr_erasure.md
Operator-facing guide documenting three modes, audit observability, salt resolution, SHA-256 hashing, atomicity guarantees, deprecation rules, and exit-code contract.
GDPR Erasure Documentation (Turkish)
docs/guides/gdpr_erasure-tr.md
Turkish translation of GDPR guide with same scope and detail.
Audit Event Catalog Updates
docs/reference/audit_event_catalog.md, docs/reference/audit_event_catalog-tr.md
Document new GDPR erasure and air-gap cache events with payload field mappings.
GDPR Erasure Tests
tests/test_gdpr_erasure.py
Comprehensive test suite covering row matching/atomicity/audit chain, salt persistence, run-scoped deletion, retention scanning, deprecation alias-forward, multi-row policy, warning conditions, and facade re-exports.

Verification Toolbelt (Phase 36)

Layer / File(s) Summary
Annex IV Verifier
forgelm/cli/subcommands/_verify_annex_iv.py
verify_annex_iv_artifact(path) loads JSON, validates required fields completeness, optionally verifies metadata.manifest_hash via compute_annex_iv_manifest_hash(...); returns structured VerifyAnnexIVResult. CLI dispatcher _run_verify_annex_iv_cmd handles file I/O, renders JSON/text output, exits success when valid.
GGUF Verifier
forgelm/cli/subcommands/_verify_gguf.py
verify_gguf(path) validates GGUF magic header, best-effort metadata parsing, optional SHA-256 sidecar verification (fails closed on malformed content). Returns VerifyGgufResult with check-level details. CLI dispatcher renders JSON/text output.
Safety Evaluation
forgelm/cli/subcommands/_safety_eval.py
_run_safety_eval_cmd enforces probe selection, rejects .gguf models, late-imports transformers, dispatches to forgelm.safety.run_safety_evaluation, maps result to JSON envelope (passed, safety_score, safe_ratio, category_distribution), exits via EXIT_SUCCESS vs EXIT_EVAL_FAILURE. Bundled default probes at forgelm/safety_prompts/default_probes.jsonl (51 entries, 14 categories).
Default Safety Probes
forgelm/safety_prompts/default_probes.jsonl
51 JSONL entries pairing prompts with risk categories (benign controls, malware, fraud, jailbreak, violence, biosecurity, self-harm, hate, harassment).
Verification Toolbelt Tests
tests/test_verification_toolbelt.py
Torch-free coverage for Annex IV round-trip (writer/verifier/tampering), GGUF magic/sidecar/malformation handling, safety-eval gate failures, and facade re-exports.
CLI Reference Update
docs/usermanuals/en/reference/cli.md, docs/usermanuals/tr/reference/cli.md
Added new subcommands to reference table (verify-annex-iv, verify-gguf, purge, cache-models, cache-tasks, safety-eval).
JSON Output Schema
docs/usermanuals/en/reference/json-output.md, docs/usermanuals/tr/reference/json-output.md
Documented JSON success envelopes, key types, and exit-code mappings for all new subcommands.

Cache Operations (Phase 35)

Layer / File(s) Summary
Cache Models & Tasks
forgelm/cli/subcommands/_cache.py
_run_cache_models_cmd late-imports huggingface_hub.snapshot_download, downloads models to Hub cache dir, measures duration/size, emits audit requested/completed/failed events. _run_cache_tasks_cmd expands task names via lm_eval.tasks.get_task_dict, invokes dataset.download_and_prepare(), stamps HF_DATASETS_CACHE env-var in finally block. Both render JSON success envelopes with per-item results and aggregate metadata.
Cache Directory Resolution
forgelm/cli/subcommands/_cache.py
_resolve_cache_dir (Hub) and _resolve_datasets_cache_dir (Datasets) implement env-var precedence chains; warn on --output divergence. Hub: --output > HF_HUB_CACHE > HF_HOME/hub. Datasets: --output > HF_DATASETS_CACHE > HF_HOME/datasets.
Cache Subcommand Wiring
forgelm/cli/_parser.py, forgelm/cli/_dispatch.py
Registrars _add_cache_models_subcommand, _add_cache_tasks_subcommand with --model/--tasks/--output args. Dispatcher routes to entrypoints.
Cache Tests
tests/test_cache_subcommands.py
Coverage for model download audit chain, task expansion/preparation, missing lm_eval install-hint errors, cache-dir resolver precedence, HF_DATASETS_CACHE env-stamping/restoration, mid-batch failure handling, and facade re-exports.
Safety Eval Documentation
docs/usermanuals/en/evaluation/safety.md, docs/usermanuals/tr/evaluation/safety.md
Updated probe set guidance: default ~50-prompt seed (not sufficient as release gate); recommendation to augment to 100+/category.

Release Management & Metadata

Layer / File(s) Summary
CHANGELOG Consolidation
CHANGELOG.md
Documents Wave 2b inline absorption rounds 1–2, closure plan (phases 16/19/21/35/36), PR #29 pre-merge dual-agent fixes, and deferred wave-2a addenda.
Release Standards
docs/standards/release.md, .claude/skills/cut-release/SKILL.md
Adds __api_version__ versioning section with MAJOR/MINOR/PATCH bump rules; updates cut-release skill to include __api_version__ bump decision checklist.
Wave 2b Merge Review Prompt
docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md, .gitignore
Self-contained pre-merge review checklist for PR #30, specifying severity rules, review focus areas by phase, cross-phase checks, and required deliverable structure; .gitignore unignores this file for tracking.
Design Document Update
docs/analysis/code_reviews/gdpr-erasure-design-202605021414.md
Updated data.erasure_requested spec to require salt_source field (allowed values: "env_var", "per_dir").
Test Robustness
tests/test_faz27_narrow_exceptions.py, tests/test_grpo_reward.py, tests/test_integration_smoke.py
Added pytest.importorskip for optional langdetect; corrected trl import targets to match lazy-loader paths; strengthened Annex IV canonical structure assertions.

Sequence Diagram

sequenceDiagram
    actor Op as Operator
    participant CLI as forgelm CLI
    participant Audit as AuditLogger
    participant FS as Filesystem
    participant Config as Config Loader

    rect rgba(100, 150, 200, 0.5)
    note over Op,Config: Row Erasure Mode (--row-id)
    Op->>CLI: forgelm purge --row-id ABC --corpus data.jsonl
    CLI->>Config: load config for audit context
    CLI->>FS: resolve salt from .forgelm_audit_salt
    CLI->>Audit: log data.erasure_requested
    CLI->>FS: scan JSONL for matching rows
    alt matches found
        CLI->>FS: atomic rewrite (drop matched lines)
        FS->>FS: fsync → os.replace
        CLI->>Audit: log data.erasure_completed
        CLI->>Op: print success envelope
    else no matches
        CLI->>Audit: log data.erasure_failed
        CLI->>Op: exit with error
    end
    end

    rect rgba(150, 100, 200, 0.5)
    note over Op,Config: Retention Policy Check (--check-policy)
    Op->>CLI: forgelm purge --check-policy --config config.yml
    CLI->>Config: strictly load retention block
    CLI->>FS: scan artefact horizons (audit log, staging, etc.)
    CLI->>Audit: compute age from first timestamp per run
    CLI->>Op: report violations (age > horizon), exit 0
    end

    rect rgba(200, 150, 100, 0.5)
    note over Op,Config: Verification & Caching
    Op->>CLI: forgelm verify-annex-iv artifact.json
    CLI->>FS: load + validate required fields
    CLI->>FS: recompute manifest hash (optional)
    CLI->>Op: print valid/invalid + reason
    
    Op->>CLI: forgelm cache-models --model llama2-7b
    CLI->>FS: resolve HF cache directory
    CLI->>Audit: log cache.populate_models_requested
    par model download
        CLI->>FS: huggingface_hub.snapshot_download(...)
        FS-->>CLI: cached model path + size
    end
    CLI->>Audit: log cache.populate_models_completed
    CLI->>Op: print success envelope
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

The PR spans five independent implementation phases with substantial new modules (purge.py, _cache.py, verify*.py), refactored library API mechanics (lazy imports + versioning), config-level deprecation reconciliation, and corresponding comprehensive test suites. While individual features are relatively self-contained, the interaction between library versioning, config deprecation logic, and multiple CLI subcommands introduces moderate density. Heterogeneous changes across audit integration, file I/O atomicity, environment variable isolation, and JSON envelope schemas demand separate reasoning per layer.

Possibly related PRs

  • cemililik/ForgeLM#22: Both PRs add/modify RetentionConfig and extend Pydantic field declarations in forgelm/config.py, directly related at the schema level.
  • cemililik/ForgeLM#13: Both PRs modify forgelm/config.py Pydantic validation logic and field aliasing (deprecation handling in this PR overlaps with validator changes in that PR).
  • cemililik/ForgeLM#11: Both PRs modify version handling in forgelm/__init__.py and introduce __version__ sourcing changes, directly related in how library versioning is managed.

Poem

A purge that hides row IDs in their hash,
Caches that stamp their env, then dash.
Annex IV artifacts squared with trust,
Lazy imports: what loads, and what's a must.
Wave 2b closes—retention takes hold,
The toolbelt blooms, and APIs unfold. 🐰✨

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch closure/wave2b-integration

@codacy-production

codacy-production Bot commented May 4, 2026 •

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 5 high · 95 minor

Alerts:
⚠ 100 issues (≤ 0 issues of at least minor severity)

Results:
100 new issues

Category Results
ErrorProne 5 high
CodeStyle 95 minor

View in Codacy

🟢 Metrics 636 complexity · 36 duplication

Metric Results
Complexity 636
Duplication 36

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces several new features and improvements, including a GDPR Article 17 erasure tool, air-gap pre-caching for models and tasks, a compliance verification toolbelt, and a library API facade. It also migrates Pydantic field definitions to include descriptions with a CI guard. The review feedback highlights that several broad exception catches (using Exception) in forgelm/cli/subcommands/_purge.py are too permissive and should be narrowed to specific exception types to improve error handling and debugging.

os.write(fd, secrets.token_bytes(_SALT_BYTES))
finally:
os.close(fd)
with open(salt_path, "rb") as fh:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The broad exception catch Exception here is too permissive and could hide unexpected bugs during the audit logging process. It is better to catch specific exceptions related to logging or I/O operations.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipped with rationale (already documented in CHANGELOG round-1 absorption notes). The broad except Exception is intentional: it is a best-effort recovery path where any unexpected exception class must funnel into the same data.erasure_failed event so a forensic reviewer sees the unfinished erasure. Narrowing would silently mask a regression class. The # noqa: BLE001 comment justifies the breadth per project standard.

if os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError: # pragma: no cover — defensive

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching Exception is too broad and can mask programming errors. Consider catching specific exceptions like OSError or PermissionError that are expected during file operations.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same rationale as the L150 sibling reply: best-effort recovery path; narrowing would silently mask a regression class. See CHANGELOG round-1 absorption notes.

Comment thread forgelm/cli/subcommands/_purge.py Outdated
from forgelm._http import _mask_netloc

targets.append(_mask_netloc(url))
except Exception: # noqa: BLE001 — best-effort redaction

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching Exception is too broad. It is safer to catch specific exceptions that might be raised by the redaction logic.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same rationale: best-effort recovery path; broad except justified per project standard with # noqa: BLE001.

Comment thread forgelm/cli/subcommands/_purge.py Outdated
from forgelm.config import load_config

return load_config(config_path)
except Exception as exc: # noqa: BLE001 — best-effort: config is optional

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Catching Exception is too broad here. Consider catching specific exceptions related to configuration loading to avoid hiding other potential issues.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same rationale: best-effort recovery path; broad except justified per project standard.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (4)
forgelm/cli/subcommands/_cache.py (1)

344-364: 💤 Low value

Unused output_format parameter.

The output_format parameter is never used in _maybe_audit_logger. Since the function only returns an AuditLogger or None (not rendering output), the parameter can be removed.

♻️ Proposed fix
-def _maybe_audit_logger(audit_dir: str, output_format: str):
+def _maybe_audit_logger(audit_dir: str):

And update the call sites at lines 142 and 256:

-    audit = _maybe_audit_logger(getattr(args, "audit_dir", None) or cache_dir, output_format)
+    audit = _maybe_audit_logger(getattr(args, "audit_dir", None) or cache_dir)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_cache.py` around lines 344 - 364, Remove the unused
output_format parameter from the function signature of _maybe_audit_logger and
its docstring; update the implementation to only accept audit_dir and return
AuditLogger|None as before. Then update all callers that currently pass an
output_format argument (the places invoking _maybe_audit_logger) to call it with
only the audit_dir value. Ensure any type hints or tests referencing
_maybe_audit_logger are adjusted to the new single-argument signature.
forgelm/cli/subcommands/_purge.py (2)

805-835: ⚖️ Poor tradeoff

Consider using a strategy pattern or lookup table for output rendering.

SonarCloud flags cognitive complexity of 24 (threshold 15). The nested conditionals for mode/dry_run combinations could be simplified with a dispatch table or by extracting per-mode renderers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_purge.py` around lines 805 - 835, The
_emit_purge_success function has high cognitive complexity due to nested
mode/dry_run branching; refactor it into a dispatch table or per-mode renderer
functions (e.g., render_row_success, render_run_success) and replace the nested
if/else with a lookup keyed by payload.get("mode") (and optionally
payload.get("dry_run") for separate dry-run handlers). Each renderer should
accept the payload and handle both dry_run and real-run output using the same
keys currently referenced (matches, first_line, row_id_hash, salt_source,
warnings, would_delete, deleted, kind, run_id, bytes_freed), and the top-level
_emit_purge_success should only select and call the appropriate renderer or
fallback to JSON printing when output_format == "json".

733-763: ⚖️ Poor tradeoff

Consider extracting timestamp parsing to reduce cognitive complexity.

SonarCloud flags cognitive complexity of 17 (threshold 15). The nested try/except blocks and conditionals could be simplified by extracting the ISO timestamp parsing into a helper function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_purge.py` around lines 733 - 763, _refactor
suggestion: The _age_from_audit_log function has high cognitive complexity due
to nested parsing/try/except/conditionals; extract the ISO timestamp parsing
into a small helper (e.g., _parse_iso_timestamp(ts: str) -> Optional[float])
that encapsulates datetime.fromisoformat replacement of "Z", timezone handling,
and ValueError handling and returns a POSIX timestamp or None; then simplify
_age_from_audit_log to iterate lines, json.loads each line, check event dict and
ts existence, call _parse_iso_timestamp to get when_ts and return now - when_ts
if present, using early continues to remove nested blocks and reduce complexity.
forgelm/cli/subcommands/_safety_eval.py (1)

138-204: ⚖️ Poor tradeoff

Consider extracting output rendering to reduce cognitive complexity.

SonarCloud flags cognitive complexity of 19 (threshold 15). The function mixes model loading, evaluation invocation, and output formatting. Extracting the output rendering (lines 180-204) into a separate helper would improve readability and testability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_safety_eval.py` around lines 138 - 204, The
_run_safety_eval_cmd function mixes evaluation logic and CLI rendering which
raises cognitive complexity; extract the block that builds prints JSON/plain
output and the final sys.exit into a new helper (e.g.,
_render_safety_eval_output(output_format, payload, model_path) or
render_safety_eval_output returning an exit code) and replace the inline
rendering (currently building marker, printing
safety_score/safe_ratio/harm_categories/failure_reason and calling sys.exit)
with a single call to that helper; ensure the helper accepts the same payload
shape, handles both "json" and human-readable paths, sorts/prints
harm_categories, and returns or calls sys.exit consistently so
_run_safety_eval_cmd only prepares payload and delegates rendering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/guides/gdpr_erasure-tr.md`:
- Line 71: The exit-code descriptions in the exit-code summary contradict the
earlier description of the `--check-policy` mode: the docs state
`--check-policy` is "report-not-gate" but the table/summary treats it as
"gate-not-report"; update the exit-code summary and the related entries
(including the block referenced around the later section with the same mismatch)
so they consistently use the "report-not-gate" wording for `--check-policy` and
flip any gate/report labels and corresponding exit-code meanings to match that
semantic across the document.

In `@docs/guides/gdpr_erasure.md`:
- Line 71: Update the exit-code table entries in docs/guides/gdpr_erasure.md so
the mode wording matches the prose: replace any "gate-not-report" phrasing with
"report-not-gate" and ensure the flag name `--check-policy` is used consistently
in those rows; specifically search for occurrences of "gate-not-report" and the
exit-code table blocks (including the second occurrence around lines 118-122)
and make the table cells match the prose's "report-not-gate" / `--check-policy`
wording.

In `@forgelm/cli/subcommands/_purge.py`:
- Around line 343-352: The loop in _purge.py is checking non-existent
WebhookConfig fields (url_success, url_failure, url_safety_alert,
url_compliance_alert) so getattr always returns None; update the code to check
the actual WebhookConfig attributes used in forgelm/config.py (at minimum "url"
and "url_env") by iterating over ("url", "url_env"), and for "url_env" resolve
the environment variable value (e.g., os.environ.get(webhook.url_env)) before
passing to _mask_netloc; keep the existing try/except redaction logic and fall
back to "<webhook-url-redacted>" if masking/resolution fails so webhook URLs
appear in targets when configured.

In `@forgelm/cli/subcommands/_safety_eval.py`:
- Line 188: The code is accessing a non-existent SafetyResult field
"harm_categories", causing empty output; change the mapping to use the correct
dataclass field "category_distribution" (e.g., replace getattr(result,
"harm_categories", {}) with getattr(result, "category_distribution", {}) and
wrap with dict(... or {})). Also update the subsequent output rendering logic
that references harm categories (the block that reads/renders harm_categories)
to instead read from category_distribution so the reported categories reflect
the real SafetyResult data.

In `@forgelm/cli/subcommands/_verify_gguf.py`:
- Around line 103-119: The code currently treats empty or non-hex sidecar
contents as OK; update the sidecar handling in the block that reads sidecar_path
(referencing _file_sha256, expected_text, expected, checks, and
VerifyGgufResult) to validate that the extracted expected token is exactly a
64‑character hex string (e.g. len==64 and all chars in 0-9a-fA-F or use a
regex); if it's missing or malformed, set checks["sidecar_present"]=True,
checks["sha256_expected"]=expected_text (or ""), set
checks["sidecar_match"]=False and return a failing VerifyGgufResult with a clear
reason like "Malformed SHA-256 sidecar: expected 64-hex digest" so malformed
sidecars fail closed instead of being ignored.
- Around line 89-97: The metadata parse branch is silently skipped when the
optional gguf dependency is missing; update the import/handling so missing gguf
raises an ImportError with install instructions instead of returning a harmless
metadata_check (apply to the code paths around _maybe_parse_metadata and
verify_gguf): detect the ImportError raised when attempting to import or use
gguf, and re-raise a new ImportError that includes a short install hint like
"install forgelm[gguf]" (or appropriate extra name) so callers of verify_gguf
fail loudly; ensure VerifyGgufResult is not returned in that case and keep the
existing metadata_parse error handling unchanged.

In `@tests/test_cache_subcommands.py`:
- Around line 190-208: The test
test_cache_tasks_missing_extra_emits_install_hint must remove any preloaded
lm_eval entries from sys.modules before patching __import__; update the test to
wrap the import-blocking patch with a patch.dict on sys.modules that pops
'lm_eval' and 'lm_eval.tasks' (set them to None or remove) so that importing
lm_eval inside _run_cache_tasks_cmd goes through the patched builtins.__import__
handler; keep the existing _block_lm_eval and call to _run_cache_tasks_cmd
unchanged.

In `@tests/test_verification_toolbelt.py`:
- Around line 152-154: The minimal GGUF fixture created by _make_minimal_gguf is
not parseable by GGUFReader when the optional gguf package is installed, causing
verify_gguf to fail; to fix the tests (e.g., test_valid_magic_passes and
test_sha256_sidecar_match_passes) mock the metadata parsing instead of changing
file bytes: in those success-path tests patch the module function that tries to
parse GGUF metadata (the function named _maybe_parse_metadata or the GGUFReader
parse call) to return a benign value (e.g., None or a minimal metadata dict) so
GGUFReader/verify_gguf will treat the fixture as valid; ensure the
monkeypatch/patch is applied before invoking verify_gguf and revert after the
test.

---

Nitpick comments:
In `@forgelm/cli/subcommands/_cache.py`:
- Around line 344-364: Remove the unused output_format parameter from the
function signature of _maybe_audit_logger and its docstring; update the
implementation to only accept audit_dir and return AuditLogger|None as before.
Then update all callers that currently pass an output_format argument (the
places invoking _maybe_audit_logger) to call it with only the audit_dir value.
Ensure any type hints or tests referencing _maybe_audit_logger are adjusted to
the new single-argument signature.

In `@forgelm/cli/subcommands/_purge.py`:
- Around line 805-835: The _emit_purge_success function has high cognitive
complexity due to nested mode/dry_run branching; refactor it into a dispatch
table or per-mode renderer functions (e.g., render_row_success,
render_run_success) and replace the nested if/else with a lookup keyed by
payload.get("mode") (and optionally payload.get("dry_run") for separate dry-run
handlers). Each renderer should accept the payload and handle both dry_run and
real-run output using the same keys currently referenced (matches, first_line,
row_id_hash, salt_source, warnings, would_delete, deleted, kind, run_id,
bytes_freed), and the top-level _emit_purge_success should only select and call
the appropriate renderer or fallback to JSON printing when output_format ==
"json".
- Around line 733-763: _refactor suggestion: The _age_from_audit_log function
has high cognitive complexity due to nested parsing/try/except/conditionals;
extract the ISO timestamp parsing into a small helper (e.g.,
_parse_iso_timestamp(ts: str) -> Optional[float]) that encapsulates
datetime.fromisoformat replacement of "Z", timezone handling, and ValueError
handling and returns a POSIX timestamp or None; then simplify
_age_from_audit_log to iterate lines, json.loads each line, check event dict and
ts existence, call _parse_iso_timestamp to get when_ts and return now - when_ts
if present, using early continues to remove nested blocks and reduce complexity.

In `@forgelm/cli/subcommands/_safety_eval.py`:
- Around line 138-204: The _run_safety_eval_cmd function mixes evaluation logic
and CLI rendering which raises cognitive complexity; extract the block that
builds prints JSON/plain output and the final sys.exit into a new helper (e.g.,
_render_safety_eval_output(output_format, payload, model_path) or
render_safety_eval_output returning an exit code) and replace the inline
rendering (currently building marker, printing
safety_score/safe_ratio/harm_categories/failure_reason and calling sys.exit)
with a single call to that helper; ensure the helper accepts the same payload
shape, handles both "json" and human-readable paths, sorts/prints
harm_categories, and returns or calls sys.exit consistently so
_run_safety_eval_cmd only prepares payload and delegates rendering.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7aaf4e15-5b9a-4b69-b0d0-c2dfd25af3af

📥 Commits

Reviewing files that changed from the base of the PR and between eeab790 and b89495f.

📒 Files selected for processing (25)
  • .github/workflows/ci.yml
  • CHANGELOG.md
  • docs/guides/gdpr_erasure-tr.md
  • docs/guides/gdpr_erasure.md
  • docs/reference/audit_event_catalog-tr.md
  • docs/reference/audit_event_catalog.md
  • forgelm/__init__.py
  • forgelm/_version.py
  • forgelm/cli/__init__.py
  • forgelm/cli/_dispatch.py
  • forgelm/cli/_parser.py
  • forgelm/cli/subcommands/_cache.py
  • forgelm/cli/subcommands/_purge.py
  • forgelm/cli/subcommands/_safety_eval.py
  • forgelm/cli/subcommands/_verify_annex_iv.py
  • forgelm/cli/subcommands/_verify_gguf.py
  • forgelm/config.py
  • forgelm/py.typed
  • forgelm/safety_prompts/default_probes.jsonl
  • pyproject.toml
  • tests/test_cache_subcommands.py
  • tests/test_gdpr_erasure.py
  • tests/test_library_api.py
  • tests/test_verification_toolbelt.py
  • tools/check_field_descriptions.py

Comment thread docs/guides/gdpr_erasure-tr.md Outdated
Comment thread docs/guides/gdpr_erasure.md Outdated
Comment thread forgelm/cli/subcommands/_purge.py Outdated
Comment thread forgelm/cli/subcommands/_safety_eval.py Outdated
Comment on lines +89 to +97
metadata_check = _maybe_parse_metadata(path)
checks["metadata_parsed"] = metadata_check["parsed"]
if metadata_check.get("error"):
# Metadata corruption is a real integrity problem.
return VerifyGgufResult(
valid=False,
reason=f"GGUF metadata block could not be parsed: {metadata_check['error']}",
checks=checks,
)

@coderabbitai coderabbitai Bot May 4, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "_verify_gguf.py" -o -name "verify_gguf.py" 2>/dev/null | head -20

Repository: cemililik/ForgeLM

Length of output: 43


🏁 Script executed:

git ls-files | grep -E "verify_gguf|gguf" | head -20

Repository: cemililik/ForgeLM

Length of output: 193


🏁 Script executed:

cat -n forgelm/cli/subcommands/_verify_gguf.py | head -150

Repository: cemililik/ForgeLM

Length of output: 6906


Raise ImportError with install hints when gguf is missing instead of silently skipping metadata verification.

The code catches the ImportError for the optional gguf dependency (line 140) and returns a silent no-error state (error: None) rather than raising with an install hint. This allows verify_gguf() to report success even when the advertised metadata verification phase never ran because the optional dependency was absent, violating the coding guideline: "Optional dependencies must raise ImportError with install hints when missing instead of silent import fallbacks."

This masks a missing extra from operators and weakens the integrity check. The guideline applies to all forgelm/**/*.py files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_verify_gguf.py` around lines 89 - 97, The metadata
parse branch is silently skipped when the optional gguf dependency is missing;
update the import/handling so missing gguf raises an ImportError with install
instructions instead of returning a harmless metadata_check (apply to the code
paths around _maybe_parse_metadata and verify_gguf): detect the ImportError
raised when attempting to import or use gguf, and re-raise a new ImportError
that includes a short install hint like "install forgelm[gguf]" (or appropriate
extra name) so callers of verify_gguf fail loudly; ensure VerifyGgufResult is
not returned in that case and keep the existing metadata_parse error handling
unchanged.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 3fc77bf (Wave 2b Round-1). Malformed SHA-256 sidecars (empty file, "TODO" placeholder, truncated paste, non-hex digest, wrong-length digest) now fail closed with a clear "Malformed SHA-256 sidecar" reason instead of being silently accepted. Sidecar tokens validated against ^[0-9a-fA-F]{64}$ regex before any digest comparison. Four new parametrised regression cases in tests/test_verification_toolbelt.py::TestVerifyGguf::test_malformed_sidecar_fails_closed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cemililik, thanks for the detailed description — the sidecar hardening is a solid fix and aligns with what the PR objectives already noted. However, the fix described addresses sidecar token validation, while the original review concern was specifically about the gguf import fallback in _maybe_parse_metadata silently returning a harmless no-error result when the gguf package is not installed.

That said, looking at this more carefully against the project's established convention (using a _HAS_<DEP> + _require_<dep>() lazy-import pattern where optional features are truly optional), the silent skip in _maybe_parse_metadata appears to be by design — the verify_gguf docstring explicitly marks the metadata block check as "optional, when the gguf package is installed". That aligns with how the project treats other optional extras like datasketch, xxhash, etc. So my original concern was likely misapplied in this context.

To summarize:

  • ✅ Sidecar hardening (your fix): malformed sidecars now fail closed via ^[0-9a-fA-F]{64}$ validation — this is a real improvement.
  • ℹ️ Metadata skip when gguf absent: per the project's opt-in lazy-import convention, this appears intentional — metadata parsing is a best-effort optional layer, not a hard gate.

✏️ Learnings added
Learnt from: cemililik
Repo: cemililik/ForgeLM PR: 30
File: forgelm/cli/subcommands/_verify_gguf.py:96-104
Timestamp: 2026-05-04T20:11:00.034Z
Learning: In cemililik/ForgeLM, `verify_gguf` in `forgelm/cli/subcommands/_verify_gguf.py` intentionally skips the GGUF metadata block parse when the optional `gguf` Python package is not installed (via `_maybe_parse_metadata`). This is by design: metadata parsing is marked as "optional, when the `gguf` package is installed" in the docstring. Do not flag the silent import fallback in `_maybe_parse_metadata` as a violation — it follows the project's established opt-in lazy-import pattern (consistent with learning 756299d4-4493-48cb-bd2a-633c8a537c1a).

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: cemililik
Repo: cemililik/ForgeLM PR: 13
File: forgelm/data_audit.py:71-79
Timestamp: 2026-04-28T11:08:48.337Z
Learning: In this project (cemililik/ForgeLM), treat heavy/optional dependencies (e.g., datasketch, xxhash, unsloth, llama_cpp) as opt-in extras: use a lazy-import pattern instead of failing eagerly at module import time. Concretely: do a top-level `try/except ImportError` that sets a `_HAS_<DEP>` boolean and assigns the missing module name to `None`, then gate feature code behind a `_require_<dep>()` helper that raises `ImportError` with an install hint only when the feature is actually invoked. Do not use a convention where importing the module always fails due to optional extras; this is intentional to keep base-install users from being forced to install optional dependencies just to use other features.

Learnt from: cemililik
Repo: cemililik/ForgeLM PR: 19
File: forgelm/judge.py:213-240
Timestamp: 2026-04-30T17:54:35.571Z
Learning: In cemililik/ForgeLM, YAML-driven numeric config fields (e.g., `JudgeConfig.batch_size`) are already validated at config-parse time using the Pydantic constraints on the corresponding config dataclass (e.g., `Field(ge=1)`). During code review, avoid flagging internal helper code (such as batching logic in functions like `_generate_responses_batched` in `forgelm/judge.py`) for missing additional runtime checks for those same YAML-derived parameters, since the Pydantic validation is the authoritative boundary gate. Only add/require runtime validation when entering true public library-API boundaries (e.g., functions like `run_safety_evaluation`) where callers may bypass the YAML/Pydantic config parsing path.

Comment thread forgelm/cli/subcommands/_verify_gguf.py Outdated
Comment thread tests/test_cache_subcommands.py
Comment thread tests/test_verification_toolbelt.py
Inline review on the Wave 2b consolidation surfaced 7 valid defects + 5
non-actionable suggestions.  All fixes minimal, behaviour-preserving
where possible, and validated against the full 1241-test suite.

Code fixes:

- _purge.py::_extract_webhook_targets: iterated over fictitious
  WebhookConfig fields (url_success / url_failure / etc.) so the
  data.erasure_warning_external_copies event always carried an empty
  webhook_targets list.  Now reads the real schema (webhook.url
  literal + webhook.url_env resolved via os.environ).  Caught
  exceptions narrowed from bare Exception to specific classes.
- _safety_eval.py:188: payload key harm_categories →
  category_distribution to match the real SafetyResult dataclass
  field.  Previous name meant getattr always returned empty dict.
- _verify_gguf.py::verify_gguf: malformed SHA-256 sidecars (empty,
  non-hex, wrong-length, "TODO" placeholder) now fail closed with a
  clear reason instead of being silently accepted.  Sidecar tokens
  validated against ^[0-9a-fA-F]{64}$ before digest comparison.

Doc fixes:

- gdpr_erasure.md:118 + gdpr_erasure-tr.md:118: exit-code table cell
  gate-not-report → report-not-gate (matches the prose at L71 and
  the design spec; the tables previously contradicted the paragraph
  immediately above them).

Test fixes:

- test_cache_subcommands.py::test_cache_tasks_missing_extra_emits_install_hint:
  explicitly pops preloaded lm_eval entries from sys.modules before
  patching builtins.__import__.  Without the pop, a sibling test
  that imports lm_eval first leaves the cached module and Python
  short-circuits the import, bypassing the patched handler.
- test_verification_toolbelt.py::TestVerifyGguf success paths gained
  a _stub_metadata_parse(monkeypatch) helper that patches
  _maybe_parse_metadata to a benign no-op so the minimal-fixture
  tests stay green when the optional gguf package is installed.
- 4 new parametrised regression cases for malformed-sidecar fail-
  closed behaviour.

Skipped (rationale in CHANGELOG):

- "Raise ImportError on missing gguf package": conflicts with
  optional-extras project standard.  Magic + SHA-256 checks remain
  useful without gguf installed.
- "Refactor _emit_purge_success / _age_from_audit_log /
  _run_safety_eval_cmd for cognitive complexity": Sonar gate already
  passes; refactor would trade complexity for indirection.
- "Drop unused output_format param from _maybe_audit_logger": real
  nit but signature touch invalidates 4 call sites for stylistic gain.
- "Narrow remaining 3 except Exception blocks": intentionally broad;
  each is a best-effort failure path where any class must funnel
  into the same operator-facing failure.  # noqa: BLE001 comments
  already justify per project standard.

Verification: ruff check + format clean, pytest 1241 passed, 14
skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

♻️ Duplicate comments (1)
forgelm/cli/subcommands/_verify_gguf.py (1)

171-174: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Raise an install-hint ImportError when gguf is missing (don’t silently skip metadata verification).

Line 173/174 currently downgrades a missing gguf dependency into a “no error” path, which lets verify_gguf continue as if metadata verification were optional-success. For this codebase, missing optional deps in forgelm/**/*.py should fail loudly with an install hint.

Suggested patch
 def _maybe_parse_metadata(path: str) -> Dict[str, Any]:
@@
     try:
         from gguf import GGUFReader  # type: ignore[import-untyped]
-    except ImportError:
-        return {"parsed": False, "error": None, "tensor_count": None}
+    except ImportError as exc:
+        raise ImportError(
+            "Optional dependency 'gguf' is required for metadata verification. "
+            "Install with: pip install 'forgelm[export]'"
+        ) from exc
@@
 def _run_verify_gguf_cmd(args, output_format: str) -> None:
@@
     try:
         result = verify_gguf(path)
+    except ImportError as exc:
+        _output_error_and_exit(
+            output_format,
+            str(exc),
+            EXIT_CONFIG_ERROR,
+        )
     except OSError as exc:
         _output_error_and_exit(
             output_format,
             f"Could not read GGUF file {path!r}: {exc}.",
             EXIT_TRAINING_ERROR,
         )
#!/bin/bash
# Verify the silent optional-dependency fallback and missing ImportError handling path.

set -euo pipefail

echo "1) Locate gguf ImportError handling:"
rg -n -C3 'from gguf import GGUFReader|except ImportError' forgelm/cli/subcommands/_verify_gguf.py

echo
echo "2) Check dispatcher exception branches:"
rg -n -C3 'try:|except ImportError|except OSError|verify_gguf\(path\)' forgelm/cli/subcommands/_verify_gguf.py

As per coding guidelines: “forgelm/**/*.py: Optional dependencies must raise ImportError with install hints when missing instead of silent import fallbacks”.

Also applies to: 207-214

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_verify_gguf.py` around lines 171 - 174, The
try/except around "from gguf import GGUFReader" silently returns a success-like
dict when gguf is missing; change it to raise an ImportError with a clear
install hint so metadata verification fails loudly. Replace the except branch in
the import block used by verify_gguf to raise ImportError("gguf is required for
GGUF verification; install with pip install gguf") (or similar), and apply the
same pattern to the other import-handling section referenced around the
GGUFReader usage so both import-failure paths provide the install hint instead
of returning {"parsed": False, "error": None, ...}.
🧹 Nitpick comments (3)
tests/test_cache_subcommands.py (2)

143-144: ⚡ Quick win

Add an exit-code assertion to anchor the audit-chain test.

The pytest.raises(SystemExit) call captures ei but never asserts ei.value.code == 0. If the command exits with a non-zero code (e.g., due to a config issue with the fake), the requested event may still be written while completed is absent — the ordering assertion at line 156 would catch that pair relationship, but the test silently permits unexpected failure paths.

✏️ Proposed fix
-            with pytest.raises(SystemExit):
+            with pytest.raises(SystemExit) as ei:
                 _cache._run_cache_models_cmd(args, output_format="json")
+        assert ei.value.code == 0
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cache_subcommands.py` around lines 143 - 144, The test currently
uses pytest.raises(SystemExit) without asserting the exit code; update the with
block that calls _cache._run_cache_models_cmd(args, output_format="json") to
capture the exception (e.g., "as ei") and add an assertion that ei.value.code ==
0 to ensure the command exited successfully; locate the with
pytest.raises(SystemExit) block around the _run_cache_models_cmd call and append
the exit-code assertion immediately after the call to anchor the audit-chain
test.

212-212: ⚡ Quick win

Remove the redundant list() wrapping _sys.modules.

The list comprehension iterates over _sys.modules without modifying it, so there is no "dictionary changed during iteration" risk. The inner list() makes an unnecessary full copy of all module keys.

✏️ Proposed fix
-    for cached in [k for k in list(_sys.modules) if k == "lm_eval" or k.startswith("lm_eval.")]:
+    for cached in [k for k in _sys.modules if k == "lm_eval" or k.startswith("lm_eval.")]:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_cache_subcommands.py` at line 212, The list comprehension
currently copies _sys.modules unnecessarily; change the iteration in the test
loop that builds cached modules (the comprehension using list(_sys.modules)) to
iterate directly over _sys.modules (i.e., remove the list() wrapper) so the for
cached in [...] comprehension uses _sys.modules and preserves the same filter
logic (k == "lm_eval" or k.startswith("lm_eval.")) without making a full copy.
forgelm/cli/subcommands/_safety_eval.py (1)

107-107: ⚡ Quick win

Add # NOSONAR to resolve SonarCloud suppression-comment syntax warnings.

SonarCloud now interprets # noqa: comments, but BLE001 is a ruff rule ID that SonarCloud does not recognise, so it flags the comment syntax as invalid. All three # noqa: BLE001 comments (lines 107, 130, 173) trigger this warning. Appending # NOSONAR keeps the ruff suppression intact while satisfying SonarCloud's format:

♻️ Proposed fix for all three occurrences
-        except Exception as exc:  # noqa: BLE001 — best-effort: GGUF loaders surface a wide failure surface (file truncation, magic mismatch, llama-cpp-python missing).
+        except Exception as exc:  # noqa: BLE001  # NOSONAR — best-effort: GGUF loaders surface a wide failure surface (file truncation, magic mismatch, llama-cpp-python missing).
-    except Exception as exc:  # noqa: BLE001 — broad surface from HF loaders (FileNotFoundError, OSError, ValueError, KeyError, etc.).
+    except Exception as exc:  # noqa: BLE001  # NOSONAR — broad surface from HF loaders (FileNotFoundError, OSError, ValueError, KeyError, etc.).
-    except Exception as exc:  # noqa: BLE001 — broad surface: classifier load failure, OOM, generation crash all funnel into one operator-facing failure path.
+    except Exception as exc:  # noqa: BLE001  # NOSONAR — broad surface: classifier load failure, OOM, generation crash all funnel into one operator-facing failure path.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_safety_eval.py` at line 107, Append " # NOSONAR" to
the existing ruff suppression comments on the broad except clauses in
forgelm/cli/subcommands/_safety_eval.py (the lines that read "except Exception
as exc:  # noqa: BLE001 — best-effort: ..."). Update all three occurrences so
each comment becomes " # noqa: BLE001 ... # NOSONAR" to preserve the ruff
suppression while satisfying SonarCloud's suppression-comment syntax.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/guides/gdpr_erasure-tr.md`:
- Around line 103-108: The docs list a non-implemented payload field and a bogus
target kind: remove `policy_check` from the `target_kind` set (the CLI
`--check-policy` does not emit erasure audit events) and remove the
`recommendation` column from the `data.erasure_warning_*` rows (the
implementation in `_purge.py` only adds `affected_run_ids`, `synthetic_files`,
and `webhook_targets` to warning events); update the table rows for
`data.erasure_warning_memorisation`,
`data.erasure_warning_synthetic_data_present`, and
`data.erasure_warning_external_copies` to mirror `_purge.py`'s actual payloads
and ensure `data.erasure_failed`/`data.erasure_completed` rows stay consistent
with their implemented fields.

In `@docs/guides/gdpr_erasure.md`:
- Around line 103-108: The table claims audit events that the code doesn't emit;
update the docs to match current behavior by removing `policy_check` from
`target_kind` (since _run_purge_check_policy() does not log erasure audit
events) and remove the `recommendation` field from all three warning rows;
instead document only the actual extra fields added by the warnings in
_purge.py: `data.erasure_warning_memorisation` includes `affected_run_ids`,
`data.erasure_warning_synthetic_data_present` includes `synthetic_files`, and
`data.erasure_warning_external_copies` includes `webhook_targets`; ensure
`data.erasure_requested`, `data.erasure_completed`, and `data.erasure_failed`
rows remain accurate (including `error_class`/`error_message` for failed events)
and that `target_id` hashing and other noted fields remain unchanged.

In `@forgelm/cli/subcommands/_purge.py`:
- Around line 724-733: The current horizons list used by the --check-policy scan
omits the per-run staging layout and the raw_documents retention horizon; update
the horizons tuple construction (the variable named horizons in _purge.py) to
include entries for the resolved run-kind staging targets (i.e., the
final_model.staging.<run_id> layout that _resolve_run_kind_targets() recognizes)
and for the raw documents retention (use retention.raw_documents_retention_days
and the path(s) that correspond to the raw_documents location), so the policy
checker inspects per-run staging directories and raw_documents artefacts when
computing overdue artefacts.
- Around line 645-648: The current check "if run_id in fname" is too loose and
can delete files whose names merely contain the run_id substring; tighten the
match in the loop that iterates over compliance_dir (the block using
compliance_dir, fname, fpath, run_id, paths) by only appending files whose
filename contains the run id as a discrete token or matches supported filename
shapes. Replace the substring check with either (a) a regex that enforces
separators/boundaries around run_id (e.g. require start/end or delimiter
characters like _, -, . around run_id) or (b) parse the run id from the filename
by splitting on known delimiters and compare equality, then append fpath only
when that strict match succeeds.
- Around line 805-821: The current _maybe_load_config silences all config load
errors which lets an invalid --config be treated as "no retention" downstream;
change _maybe_load_config to accept a strict: bool flag (default False) and, on
exception from load_config, re-raise the exception when strict is True (keep the
current debug/log+None behavior when strict is False). Then update callers that
perform strict validation (specifically _run_purge_check_policy) to call
_maybe_load_config(config_path, strict=True) so real ConfigError/load failures
surface for --check-policy, while keeping the best-effort fallback for other
non-critical callers.

In `@forgelm/cli/subcommands/_safety_eval.py`:
- Around line 138-210: The function _run_safety_eval_cmd exceeds allowed
cognitive complexity due to the output rendering block; extract that rendering
logic into a new helper function (e.g., _emit_safety_result) that accepts the
payload and output_format and performs the JSON vs human-printing and
category/failure printing, then replace the existing rendering lines in
_run_safety_eval_cmd with a single call to _emit_safety_result(payload,
output_format). Ensure the helper is unit-testable, imports json/sys as needed,
and preserve behavior including EXIT selection via sys.exit after the helper
returns.
- Around line 105-106: The import error arises because load_gguf_model is
referenced from forgelm.inference but is not implemented; either implement
load_gguf_model in the inference module to return a (model, tokenizer) tuple (to
match the unpacking used by the safety-eval command) and update its
docstring/typing, or remove GGUF detection/handling from the safety-eval
model-path logic so it no longer tries to import load_gguf_model; also update
the exception message that currently suggests installing [export] to accurately
reflect that GGUF loading is unimplemented. Ensure you touch the symbols
load_gguf_model, the safety-eval model-path detection/unpacking, and the
ImportError handling to keep behavior and logs consistent.

---

Duplicate comments:
In `@forgelm/cli/subcommands/_verify_gguf.py`:
- Around line 171-174: The try/except around "from gguf import GGUFReader"
silently returns a success-like dict when gguf is missing; change it to raise an
ImportError with a clear install hint so metadata verification fails loudly.
Replace the except branch in the import block used by verify_gguf to raise
ImportError("gguf is required for GGUF verification; install with pip install
gguf") (or similar), and apply the same pattern to the other import-handling
section referenced around the GGUFReader usage so both import-failure paths
provide the install hint instead of returning {"parsed": False, "error": None,
...}.

---

Nitpick comments:
In `@forgelm/cli/subcommands/_safety_eval.py`:
- Line 107: Append " # NOSONAR" to the existing ruff suppression comments on the
broad except clauses in forgelm/cli/subcommands/_safety_eval.py (the lines that
read "except Exception as exc:  # noqa: BLE001 — best-effort: ..."). Update all
three occurrences so each comment becomes " # noqa: BLE001 ... # NOSONAR" to
preserve the ruff suppression while satisfying SonarCloud's suppression-comment
syntax.

In `@tests/test_cache_subcommands.py`:
- Around line 143-144: The test currently uses pytest.raises(SystemExit) without
asserting the exit code; update the with block that calls
_cache._run_cache_models_cmd(args, output_format="json") to capture the
exception (e.g., "as ei") and add an assertion that ei.value.code == 0 to ensure
the command exited successfully; locate the with pytest.raises(SystemExit) block
around the _run_cache_models_cmd call and append the exit-code assertion
immediately after the call to anchor the audit-chain test.
- Line 212: The list comprehension currently copies _sys.modules unnecessarily;
change the iteration in the test loop that builds cached modules (the
comprehension using list(_sys.modules)) to iterate directly over _sys.modules
(i.e., remove the list() wrapper) so the for cached in [...] comprehension uses
_sys.modules and preserves the same filter logic (k == "lm_eval" or
k.startswith("lm_eval.")) without making a full copy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2fda9cab-fd57-4a50-81dc-196231d29b3f

📥 Commits

Reviewing files that changed from the base of the PR and between b89495f and 3fc77bf.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • docs/guides/gdpr_erasure-tr.md
  • docs/guides/gdpr_erasure.md
  • forgelm/cli/subcommands/_purge.py
  • forgelm/cli/subcommands/_safety_eval.py
  • forgelm/cli/subcommands/_verify_gguf.py
  • tests/test_cache_subcommands.py
  • tests/test_verification_toolbelt.py
✅ Files skipped from review due to trivial changes (1)
  • CHANGELOG.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_verification_toolbelt.py

Comment thread docs/guides/gdpr_erasure-tr.md Outdated
Comment thread docs/guides/gdpr_erasure.md Outdated
Comment thread forgelm/cli/subcommands/_purge.py Outdated
Comment thread forgelm/cli/subcommands/_purge.py Outdated
Comment thread forgelm/cli/subcommands/_purge.py Outdated
Comment thread forgelm/cli/subcommands/_safety_eval.py Outdated
Comment thread forgelm/cli/subcommands/_safety_eval.py Outdated
cemililik and others added 5 commits May 4, 2026 22:31
…red)

Two pre-merge agent reviews of PR #29 (`development → main`
release-PR consolidating Wave 1 + Wave 2a) — `opusreview-pr29.md`
(Opus 4.7, correctness + cohesion + CI bütünlük) and
`kimireview-pr29.md` (Kimi, code-correctness focus) — surfaced one
HIGH that was load-bearing across the approve / reject family plus
seven MEDIUM / LOW / NIT defensive-coding gaps.  10 of 11 findings
shipped fixes; F-PR29-03 (cohesion: `_assert_audit_log_readable_or_exit`
in `_approve.py` rather than `_audit_log_reader.py`) deferred to
Phase 21 with rationale.

Code:

- `forgelm/compliance.py::generate_training_manifest` — webhook config
  now persisted into the manifest (and therefore into the co-located
  `compliance_report.json` the approve / reject dispatchers read).
  Without this stanza `_build_approval_notifier` saw `webhook_cfg=None`
  and silently no-op'd `notify_success` / `notify_failure`; an operator
  with a valid Slack / Teams webhook in the training YAML got the
  "awaiting approval" notification but **never** the follow-up
  success / rejection notification.  (Kimi F-37-01 — HIGH)
- `forgelm/cli/_result.py::_output_result` — `default=str` on
  `json.dumps` so a `TrainResult.resource_usage` containing a `Path`
  / `datetime` / numpy scalar cannot crash with `TypeError`.
  Mirrors Wave 2a Round-5 F-R5-06.  (Kimi F-TRAIN-01 — MEDIUM)
- `forgelm/cli/_dispatch.py::main` — extracted body to `_main_inner`
  + top-level `try/except KeyboardInterrupt → sys.exit(EXIT_TRAINING_ERROR)`.
  Ctrl-C during `parse_args()` / wizard / config-load no longer
  bubbles up as shell-shaped 130.  (Kimi F-CLI-01 — LOW)
- `forgelm/cli/subcommands/_doctor.py::_render_json` — `ensure_ascii=False`
  so Turkish operator names + Unicode paths render as literal
  characters, not `\uXXXX` escapes.  (Kimi F-34-01 — LOW)
- `forgelm/cli/subcommands/_doctor.py::_run_doctor_cmd` — env-var scan
  now also checks `HF_DATASETS_OFFLINE=1`.  Mirrors what
  `_apply_offline_flag` already sets at training time.
  (Kimi F-XPR-01 — LOW)
- `forgelm/cli/subcommands/_doctor.py::_walk_hf_cache_bounded` — log
  message prints `?` (not literal `None`) when `OSError.filename`
  is unset.  (Opus F-PR29-06 — LOW)
- `forgelm/cli/subcommands/_approvals.py::_emit_show_json` and
  `_emit_pending_json` — `default=str` + `ensure_ascii=False`.
  (Kimi F-37-02 — NIT)
- `tests/test_grpo_reward.py::test_reward_funcs_is_callable_list` —
  patch targets corrected from `forgelm.trainer.SFTTrainer` /
  `forgelm.trainer.SFTConfig` to `trl.SFTTrainer` / `trl.SFTConfig`
  (lazy-import contract: not module-level on `forgelm.trainer`).
  Fix unblocks PR #29 CI matrix on all four Python versions.
  (Opus F-PR29-01.b — HIGH; CI matrix unblocker)
- `tests/test_faz27_narrow_exceptions.py::test_english_payload_returns_en`
  — `pytest.importorskip("langdetect")`.  CI matrix uses `[dev]`
  only; `langdetect` is under the optional `[ingestion]` extra.
  Fix unblocks PR #29 CI matrix.  (Opus F-PR29-01.a — HIGH; CI
  matrix unblocker)

Docs:

- `docs/analysis/code_reviews/gdpr-erasure-design-202605021414.md`
  §5.1 event catalog row for `data.erasure_requested` now lists
  `salt_source` (row mode only).  Wave 2a Round-5 F-R5-05 introduced
  the field in §5.4 but did not propagate it to §5.1; Phase 21
  implementer following §5.1 alone would have missed the salt-source
  persistence and silently regressed F-R5-05's hash-discontinuity
  detection property.  (Opus F-PR29-02 — HIGH)

Verification: ruff format + check clean; pytest 1161 passed,
14 skipped, 0 failed (was 1160 passed, 2 failed on PR #29 CI matrix);
forgelm --config config_template.yaml --dry-run exits 0.

Deferred with rationale (documented in CHANGELOG):

- Opus F-PR29-03 (cohesion of `_assert_audit_log_readable_or_exit`)
- Opus F-PR29-04 (collapse triple `os.path.isfile` into enum helper)
- Opus F-PR29-05 (test-injection env var harden)
- Opus F-PR29-07 (verify-audit option-error code 2 vs 1 ambiguity)
- Opus F-PR29-09 (orchestrator test-injection pragma: no cover)
- Kimi F-INFRA-01 (`_get_version` duplication)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…kip)

Round 2 of inline review on Wave 2b consolidation.  Six valid defects
+ three actionable nits fixed; one duplicate (gguf ImportError) skipped
with rationale.  All fixes minimal, behaviour-preserving where
possible.

Code fixes:

- _purge.py::_resolve_run_kind_targets: tightened compliance-bundle
  filename match.  Previous `run_id in fname` could delete files
  belonging to a different run when the short run-id was a substring.
  Now uses token-boundary helper _filename_contains_run_id requiring
  flanking _ / - / . or string edges.
- _purge.py::_scan_retention_violations: expanded horizons tuple to
  include per-run staging dirs (final_model.staging.<run_id>/ — what
  the trainer creates since Phase 9 v2) and raw_documents horizon
  (covers raw_documents/ + legacy ingestion_output/).  New helpers
  _discover_per_run_staging_horizons + _discover_raw_documents_horizons.
- _purge.py::_maybe_load_config: added strict=False param.
  --check-policy now invokes with strict=True so malformed YAML /
  Pydantic validation surfaces as EXIT_CONFIG_ERROR; row-id / run-id
  paths keep silent-degrade-to-None (config-agnostic by design).
- _safety_eval.py::_load_model_for_safety: removed phantom GGUF
  branch.  forgelm.inference has no load_gguf_model function;
  operators see honest "not yet supported" message naming the
  Phase 36+ shim instead of misleading [export]-extra hint.
- _safety_eval.py: extracted _emit_safety_result +
  _build_safety_eval_payload helpers to drop Sonar S3776 cognitive
  complexity.  Behaviour-preserving; helpers in __all__ for tests.
- _safety_eval.py: appended # NOSONAR to broad-except suppressions.

Doc fixes:

- gdpr_erasure(.md|-tr.md):103-108 + audit_event_catalog(.md|-tr.md):61
  — removed `policy_check` from target_kind (--check-policy emits no
  events) + removed `recommendation` column from warning rows
  (_detect_warning_conditions doesn't emit it).  Tables now mirror
  what the code actually emits.

Test fixes:

- test_cache_subcommands.py audit-chain test: added
  ei.value.code == 0 anchor so the chain assertions don't silently
  pass on a code-1 / code-2 run.
- test_cache_subcommands.py lm_eval pop: dropped redundant list()
  wrapper around _sys.modules.

Skipped (duplicate of Round-1):

- "Raise ImportError on missing gguf package": optional-extras
  project standard; magic + SHA-256 checks remain useful without it.

Verification: ruff check + format clean, pytest 1241 passed, 14
skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
11 SonarCloud findings on PR #30 absorbed.  All fixes minimal,
behaviour-preserving, validated against the full 1241-test suite.

- S5713 _purge.py:720: dropped redundant `except Exception` fallback
  on the strict config-load path.  The (ConfigError, OSError) tuple
  already covers every documented failure mode of `load_config`; any
  other class would be a contract violation in the loader and should
  propagate so we fix the root cause rather than mask it.
- S1192 __init__.py: extracted six submodule-path constants
  (_M_DATA_AUDIT, _M_COMPLIANCE, _M_VERIFY_ANNEX_IV, _M_VERIFY_GGUF,
  _M_UTILS, _M_BENCHMARK) so the _LAZY_SYMBOLS dict no longer
  duplicates the same string 7+ times.  Single-source rename target.
- S7632 _cache.py:158, 281 + _verify_gguf.py:179: appended ` # NOSONAR`
  to the broad-except suppression comments so SonarCloud's
  suppression-comment syntax is satisfied alongside ruff's
  `# noqa: BLE001`.
- S1172 _cache.py:344: dropped the unused `output_format` parameter
  from `_maybe_audit_logger`; updated both call sites.
- S3776 _purge.py x4 (complexity 16/17/17/24): refactored:
  * `_age_from_audit_log` (24 → ~6) split into
    `_parse_iso_timestamp_to_posix` + `_genesis_timestamp_from_lines`
    helpers.
  * `_emit_purge_success` (17 → ~5) replaced if/elif chain with a
    `_TEXT_RENDERERS` dict + per-mode `_render_*_success` helpers.
    Adding a new purge mode is now a one-row + one-helper edit.
  * `_run_purge_row_id` (17 → ~6) extracted
    `_validate_match_count_or_fail`, `_emit_row_dry_run`, and
    `_perform_row_erasure_and_audit` so the dispatcher reads
    top-down (validate → branch dry-run → real erasure).
  * `_run_purge_check_policy` (16 → 14) — already partially fixed
    by the S5713 broad-except removal; remaining branches now stay
    under the ceiling.

All refactors are behaviour-preserving — no test changes needed.

Verification: ruff check + format clean, pytest 1241 passed,
14 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SonarCloud S3776 still flagged _resolve_run_kind_targets at complexity
17 after the round-3 refactor (the staging + artefacts branches each
carried their own nested checks).  Replaced the if/elif chain with a
_RUN_KIND_RESOLVERS dispatch dict + two per-kind helpers
(_staging_targets_for_run, _artefact_targets_for_run).  Adding a new
--kind value is now a one-row + one-helper edit.

Behaviour-preserving (28 Phase 21 tests still green; full suite stays
at 1241 passed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
F-W2B-01 (HIGH) + F-W2B-05: reconcile Annex IV writer/verifier shape
drift.  ``compliance.export_compliance_artifacts`` previously wrote
``manifest["annex_iv"]`` verbatim (the operator-supplied 7-key
provider block); ``verify-annex-iv`` then rejected it because the
verifier checks Annex IV §1-9 categories (system_identification,
system_components, data_governance, …).  Added
``build_annex_iv_artifact(manifest)`` which synthesises the §1-9
layout from manifest sub-blocks while preserving the operator block
under ``provider_metadata``.  Added
``compute_annex_iv_manifest_hash(artifact)`` shared by writer +
verifier so canonicalisation cannot drift byte-for-byte.  Writer now
stamps ``metadata.manifest_hash`` so the verifier's tampering branch
fires on round-trip.  ``_verify_annex_iv._compute_manifest_hash``
delegates to the shared helper.  Regression tests cover writer→
verifier round-trip + tampering rejection.

F-W2B-02 (HIGH): switch RetentionConfig migration heuristic from
value-comparison to Pydantic v2 ``model_fields_set``.  The previous
"legacy != default" check could not distinguish "operator omitted
the legacy field" from "operator wrote the default value
explicitly", so a YAML that set only the canonical block could be
silently overridden by Pydantic-filled legacy defaults.  Regression
test asserts canonical block wins when YAML omits the legacy key.

F-W2B-03: ``_atomic_rewrite_dropping_lines`` now ``flush() +
os.fsync()`` before ``os.replace()`` so a crash mid-purge cannot
leave a half-written audit log on disk.  Regression test spies on
fsync + replace to assert ordering.

F-W2B-04: ``forgelm cache models|tasks --output <dir>`` now warns
when ``<dir>`` diverges from the env-resolved cache (HF_HUB_CACHE >
HF_HOME/hub > ~/.cache/huggingface/hub) so operators don't end up
warming a directory ``forgelm doctor`` later flags as misconfigured.

F-W2B-06: ``_scan_run_ids_with_final_model`` falls back to walking
the audit log for ``human_approval.granted`` events when no staging
directories survive (already-promoted runs) so the listing remains
complete after promotion.

F-W2B-07: ``tools/check_field_descriptions.py`` now recognises the
Pydantic v2 ``Annotated[T, Field(..., description=...)]`` form so
the CI gate stops false-flagging valid v2 idioms.

F-W2B-08: ``_verify_gguf.py:113`` sidecar read now uses an explicit
context manager (file handle was previously left to GC).

F-W2B-09 (skip): PR description refresh — not a code change.

Round-4 absorption keeps the existing 1245-test suite green and
ships the writer/verifier reconciliation that the verifier toolbelt
needed to be self-consistent on a freshly exported artefact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/test_grpo_reward.py (1)

290-322: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

TestGrpoClassifierTrustRemoteCode is missing the torch_available skip guard.

The file-level comment on line 12 states "ForgeTrainer requires torch — skip all tests if not available", and TestGrpoRewardCallable carries that guard. TestGrpoClassifierTrustRemoteCode imports ForgeTrainer at line 306 inside the test but has no @pytest.mark.skipif on the class. In an environment where torch is absent and forgelm/trainer.py imports torch at module scope, this test will error with ImportError rather than being skipped cleanly.

🛡️ Proposed fix
+@pytest.mark.skipif(not torch_available, reason="torch not installed")
 class TestGrpoClassifierTrustRemoteCode:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_grpo_reward.py` around lines 290 - 322, Add the pytest skip guard
to the TestGrpoClassifierTrustRemoteCode class so the whole class is skipped
when torch isn't available: wrap the class with the same pytest.mark.skipif
condition used elsewhere (torch_available) or annotate the class with
`@pytest.mark.skipif`(not torch_available, reason="ForgeTrainer requires torch")
so importing ForgeTrainer inside test_classifier_reward_passes_trust_remote_code
does not raise ImportError when torch is absent.
🧹 Nitpick comments (1)
tests/test_faz27_narrow_exceptions.py (1)

43-63: 💤 Low value

LGTM — pytest.importorskip guard is correctly placed and the reason kwarg is valid.

The reason keyword argument to pytest.importorskip is fully supported and will surface the provided string as the skip message when langdetect cannot be imported.

One minor note: the return value of pytest.importorskip is the imported module, and the docs recommend it "should be assigned to its canonical name." Since the module itself isn't used in the test body (only _detect_language is needed), the discarded return value is harmless — but assigning it avoids any future linter noise:

🔧 Optional: assign the return value
-        pytest.importorskip(
+        langdetect = pytest.importorskip(  # noqa: F841
             "langdetect",
             reason=(
                 "_detect_language is constant 'unknown' without the optional "
                 "[ingestion] extra; this happy-path assertion only meaningful "
                 "when langdetect is installed."
             ),
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_faz27_narrow_exceptions.py` around lines 43 - 63, The
pytest.importorskip call currently discards its return value; assign it to the
canonical module name to avoid linter noise and follow docs: change the call to
capture the result (e.g. langdetect = pytest.importorskip(...)) while keeping
the same reason string and leaving the rest of the test using _detect_language
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CHANGELOG.md`:
- Around line 50-53: Change the Markdown code span that currently contains a
leading space (written as ` ` # NOSONAR` `) to remove the extra space so it
reads `` `# NOSONAR` ``; update the CHANGELOG.md line referencing the filename
and adjust the inline code span around # NOSONAR to eliminate the inner space so
markdownlint MD038 no longer flags it.

In `@docs/analysis/code_reviews/gdpr-erasure-design-202605021414.md`:
- Line 242: The docs table entry for data.erasure_requested incorrectly lists
`policy_check` as a possible value of `target_kind`; remove `policy_check` from
the `target_kind` set in the `data.erasure_requested` row so the allowed values
read `{row, staging, artefacts, policy_check}` → `{row, staging, artefacts}`
(and ensure any accompanying mention of `policy_check` in that cell is deleted),
since `--check-policy` is read-only and should not generate an erasure request.

In `@docs/guides/gdpr_erasure-tr.md`:
- Around line 90-93: Update the "legacy-only" bullet to match the actual parser
behavior: explain that _reconcile_staging_ttl_days consults model_fields_set, so
any explicitly set key (e.g., evaluation.staging_ttl_days: 7) is treated as
"set" and will trigger alias-forwarding to retention.staging_ttl_days and emit a
DeprecationWarning; keep the existing descriptions for the canonical-only,
identical-values (DeprecationWarning, canonical wins), and differing-values
(raise ConfigError) cases but clarify that "set" is determined by
model_fields_set rather than "non-default value" wording.

In `@docs/guides/gdpr_erasure.md`:
- Around line 69-71: Update the guide text around the --check-policy behavior to
clarify that it does not unconditionally exit 0: change the "Always exits 0"
statement to say "Successful policy reports exit 0; config-load failures exit
with EXIT_CONFIG_ERROR (non-zero)" and mention that explicit --config
missing/invalid/unreadable returns EXIT_CONFIG_ERROR; apply the same
clarification to the other occurrence referenced (lines 118-122) so the docs
match the implementation that returns a config error instead of unconditional 0.

In `@forgelm/cli/subcommands/_cache.py`:
- Around line 308-359: The command resolves cache_dir but never hands it to the
datasets layer; update the plumbing so the chosen cache_dir is actually used:
change _prepare_one_task(name, task_obj) to accept a cache_dir parameter and
thread that value into whatever code calls datasets.load_dataset(...,
cache_dir=cache_dir) or dataset.download_and_prepare(cache_dir=cache_dir);
alternatively set os.environ["HF_DATASETS_CACHE"]=cache_dir before
resolving/preparing each task in _prepare_one_task; ensure the same change is
applied to the other cache-tasks code path that emits success (the other call
site of _prepare_one_task/_emit_cache_success) so the audit/logged cache_dir
matches where artifacts are stored.

In `@forgelm/cli/subcommands/_purge.py`:
- Around line 927-950: The scan is reusing a single audit_age_seconds for all
artefacts; change it to resolve age per artefact by consulting the audit log for
that artefact's run_id instead of a single genesis timestamp: for each horizon
entry produced by _discover_per_run_staging_horizons and
_discover_raw_documents_horizons, extract the run_id (or otherwise identify the
owning run), call a per-run lookup (extend _age_from_audit_log or add a helper
like _age_from_audit_log_for_run) to obtain that run's audit age, and only if
that lookup fails fall back to mtime via _resolve_artefact_age; update the loop
that calls _resolve_artefact_age to pass the per-artifact audit age (or allow
_resolve_artefact_age to accept a run_id and perform the lookup internally) so
each artefact uses its correct run-specific audit timestamp.

In `@forgelm/cli/subcommands/_safety_eval.py`:
- Around line 18-25: Update the exit handling and header comment so a model that
completes evaluation but fails the safety gate uses the evaluation-failure exit
code; specifically, change any exit that currently returns EXIT_CONFIG_ERROR for
the non-passing safety path to EXIT_EVAL_FAILURE (search for uses of
EXIT_CONFIG_ERROR in the safety-eval logic, including the non-passing branch
where the command completes but produces a negative safety verdict and the
occurrences referenced around the current lines ~190-191), and update the header
comment block that lists exit codes to reflect "1 — evaluation completed but
safety thresholds exceeded (operator-actionable; the model failed the gate)"
maps to EXIT_EVAL_FAILURE. Ensure you only change the error code constant and
the comment text, leaving runtime error exits (EXIT_RUNTIME_ERROR) and
config-error semantics unchanged.

In `@forgelm/compliance.py`:
- Around line 718-755: The code currently persists the entire webhook model (via
webhook_cfg.model_dump(mode="json")) which can include a raw credential in the
"url" field; instead, when building manifest["webhook_config"] only persist
non-secret/env-backed fields (e.g. "url_env", "secret_env", "notify_on_success",
"notify_on_failure", "notify_on_revert", "notify_on_awaiting_approval",
"timeout_seconds", "retry_count", "retry_backoff_seconds") and explicitly omit
or redact "url"; update both the model_dump path (call model_dump then drop or
replace the "url" key) and the AttributeError fallback (do not include
getattr(webhook_cfg, "url", ...) there) so no literal webhook URL is written to
the compliance artifact.

In `@forgelm/config.py`:
- Around line 963-1007: The code treats the retention default (e.g. 7) as an
explicit canonical value, causing false conflicts; fix by only treating
retention.staging_ttl_days as canonical when it was explicitly provided. Detect
explicitness the same way you do for evaluation: add
canonical_was_explicitly_set = bool(self.retention is not None and
"staging_ttl_days" in self.retention.model_fields_set) and set canonical =
self.retention.staging_ttl_days if canonical_was_explicitly_set else None, then
use that canonical (and its explicitness) in the subsequent branches instead of
assuming any retention object implies an explicit staging_ttl_days.

---

Outside diff comments:
In `@tests/test_grpo_reward.py`:
- Around line 290-322: Add the pytest skip guard to the
TestGrpoClassifierTrustRemoteCode class so the whole class is skipped when torch
isn't available: wrap the class with the same pytest.mark.skipif condition used
elsewhere (torch_available) or annotate the class with `@pytest.mark.skipif`(not
torch_available, reason="ForgeTrainer requires torch") so importing ForgeTrainer
inside test_classifier_reward_passes_trust_remote_code does not raise
ImportError when torch is absent.

---

Nitpick comments:
In `@tests/test_faz27_narrow_exceptions.py`:
- Around line 43-63: The pytest.importorskip call currently discards its return
value; assign it to the canonical module name to avoid linter noise and follow
docs: change the call to capture the result (e.g. langdetect =
pytest.importorskip(...)) while keeping the same reason string and leaving the
rest of the test using _detect_language unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6b203deb-50c8-4bbd-894b-42cbd39a124f

📥 Commits

Reviewing files that changed from the base of the PR and between 3fc77bf and 51adb42.

📒 Files selected for processing (25)
  • CHANGELOG.md
  • docs/analysis/code_reviews/gdpr-erasure-design-202605021414.md
  • docs/guides/gdpr_erasure-tr.md
  • docs/guides/gdpr_erasure.md
  • docs/reference/audit_event_catalog-tr.md
  • docs/reference/audit_event_catalog.md
  • forgelm/__init__.py
  • forgelm/cli/_dispatch.py
  • forgelm/cli/_result.py
  • forgelm/cli/subcommands/_approvals.py
  • forgelm/cli/subcommands/_cache.py
  • forgelm/cli/subcommands/_doctor.py
  • forgelm/cli/subcommands/_purge.py
  • forgelm/cli/subcommands/_safety_eval.py
  • forgelm/cli/subcommands/_verify_annex_iv.py
  • forgelm/cli/subcommands/_verify_gguf.py
  • forgelm/compliance.py
  • forgelm/config.py
  • tests/test_cache_subcommands.py
  • tests/test_faz27_narrow_exceptions.py
  • tests/test_gdpr_erasure.py
  • tests/test_grpo_reward.py
  • tests/test_integration_smoke.py
  • tests/test_verification_toolbelt.py
  • tools/check_field_descriptions.py
✅ Files skipped from review due to trivial changes (2)
  • tests/test_cache_subcommands.py
  • docs/reference/audit_event_catalog.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • docs/reference/audit_event_catalog-tr.md
  • forgelm/cli/subcommands/_verify_annex_iv.py
  • forgelm/cli/subcommands/_verify_gguf.py
  • tests/test_verification_toolbelt.py
  • forgelm/init.py
  • tests/test_gdpr_erasure.py

Comment thread CHANGELOG.md Outdated
Comment thread docs/analysis/code_reviews/gdpr-erasure-design-202605021414.md Outdated
Comment thread docs/guides/gdpr_erasure-tr.md Outdated
Comment thread docs/guides/gdpr_erasure.md Outdated
Comment thread forgelm/cli/subcommands/_cache.py Outdated
Comment thread forgelm/cli/subcommands/_purge.py Outdated
Comment thread forgelm/cli/subcommands/_safety_eval.py
Comment thread forgelm/compliance.py Outdated
Comment thread forgelm/config.py Outdated
cemililik and others added 2 commits May 5, 2026 01:13
Code (8 fixes):

- ``forgelm/cli/subcommands/_cache.py`` — ``cache-tasks`` now stamps
  ``HF_DATASETS_CACHE=<cache_dir>`` before the loop AND threads
  ``cache_dir`` into ``_prepare_one_task`` so the underlying
  ``datasets.download_and_prepare()`` writes parquet shards under the
  resolved cache_dir instead of silently landing under
  ``~/.cache/huggingface/datasets``.  Without this the JSON-envelope
  ``cache_dir`` claim and the on-disk artefact location diverged,
  defeating the air-gap workflow.
- ``forgelm/cli/subcommands/_purge.py`` — retention scan now ages each
  artefact against its OWN audit timestamp.  Builds
  ``{None: genesis_age, run_id: age_for_run}`` once per scan; the two
  discovery helpers (``_discover_per_run_staging_horizons`` /
  ``_discover_raw_documents_horizons``) emit 4-tuples carrying the
  owning ``run_id``; ``_resolve_artefact_age(path, audit_ages, run_id,
  now)`` looks up per-run age first, falls back to genesis, then
  mtime.  Previously every per-run staging dir was aged from the
  merged-log genesis timestamp — wildly wrong for runs created weeks
  apart.  Removed the now-orphaned ``_age_from_audit_log`` +
  ``_genesis_timestamp_from_lines`` helpers.
- ``forgelm/cli/subcommands/_safety_eval.py`` — non-passing safety
  branch now exits ``EXIT_EVAL_FAILURE`` (3) instead of
  ``EXIT_CONFIG_ERROR`` (1).  A model that completes evaluation but
  fails the gate is an evaluation failure, not a config error;
  regulated CI pipelines can now distinguish "the gate said no" from
  "the run never started because the config was rejected".  Header
  exit-code list updated to add the new code-3 entry.
- ``forgelm/compliance.py`` — ``manifest["webhook_config"]`` no
  longer carries the literal ``url`` field.  The Slack/Teams URL can
  embed a webhook secret in its path; persisting it verbatim into a
  plain-JSON compliance artefact (typically committed to the
  auditor's evidence bundle) leaks the credential.  We now persist
  only env-backed indirection fields (``url_env``, ``secret_env``,
  ``notify_on_*``, ``timeout_seconds``, ``retry_*``) so the approve/
  reject dispatchers can re-resolve the webhook at runtime without
  any credential reaching disk.  Both the ``model_dump`` path and
  the AttributeError fallback are updated.
- ``forgelm/config.py`` — retention reconcile now applies the same
  ``model_fields_set`` test to the canonical block as it does to the
  legacy block.  An operator who writes ``retention:
  {audit_log_retention_days: 1825}`` (no staging key) leaves
  ``staging_ttl_days`` at its default of 7; treating that 7 as an
  explicit canonical value would spuriously raise ``ConfigError``
  when paired with ``evaluation.staging_ttl_days: 14``.  The
  canonical value is now ``None`` unless the operator actually wrote
  the field.

Tests (2 fixes):

- ``tests/test_grpo_reward.py`` — ``TestGrpoClassifierTrustRemoteCode``
  now carries a class-level ``@pytest.mark.skipif(not torch_available)``
  so importing ``ForgeTrainer`` inside the test does not raise
  ImportError on torch-less matrix builds.
- ``tests/test_faz27_narrow_exceptions.py`` — capture
  ``langdetect = pytest.importorskip(...)`` (with ``# noqa: F841``)
  per pytest docs so the binding documents the optional dep
  relationship.

Docs (4 fixes):

- ``CHANGELOG.md`` — drop the leading space inside the
  ```# NOSONAR``` code span (markdownlint MD038).
- ``docs/analysis/code_reviews/gdpr-erasure-design-202605021414.md``
  — remove ``policy_check`` from the ``data.erasure_requested``
  ``target_kind`` set; ``--check-policy`` is read-only and emits no
  audit events.
- ``docs/guides/gdpr_erasure.md`` + ``-tr.md`` — clarify that the
  deprecation reconciler keys on Pydantic v2 ``model_fields_set``
  (operator-explicit) rather than "non-default value", and that
  ``--check-policy`` exits ``EXIT_CONFIG_ERROR`` (non-zero) when the
  supplied ``--config`` is missing/unreadable/invalid (no longer
  "always exits 0").  Exit-code table updated for parity in both
  language mirrors.

Skips:

- F-W2B-09 (PR description refresh) — tracked separately, not a code
  change.

Validation: ``ruff format`` clean, ``ruff check`` clean, ``pytest``
1245 passed / 14 skipped, ``forgelm --dry-run config_template.yaml``
green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CodeRabbit follow-ups on Round-5 (4d636c6):

- ``forgelm/config.py`` — alias-forward path now uses
  ``retention.model_copy(update={"staging_ttl_days": legacy})`` instead
  of ``RetentionConfig(staging_ttl_days=legacy)`` so an operator who
  set ``retention: {audit_log_retention_days: 1825}`` plus
  ``evaluation.staging_ttl_days: 14`` does not lose the explicit
  ``audit_log_retention_days`` value when the deprecation reconciler
  fires.
- ``forgelm/cli/subcommands/_cache.py`` — ``cache-tasks`` now resolves
  via the *Datasets* env chain (``HF_DATASETS_CACHE > HF_HOME/datasets
  > ~/.cache/huggingface/datasets``) instead of the Hub chain.  HF
  treats hub vs datasets caches as separate roots; the previous code
  set ``HF_DATASETS_CACHE`` to the hub-shaped path so parquet shards
  landed in ``hub/`` instead of ``datasets/``.  Added
  ``_resolve_env_datasets_cache_dir`` + ``_resolve_datasets_cache_dir``
  helpers; ``_warn_on_cache_dir_divergence`` now takes a ``kind``
  argument so the warning names the right env var per subcommand.

SonarCloud Quality Gate failures (5 issues):

- ``forgelm/config.py:966+979`` (S2259, None-narrowing) — bind
  ``self.evaluation`` / ``self.retention`` to local variables before
  the ``model_fields_set`` check + attribute access, so the static
  analyser sees the same narrowing the runtime ``is not None`` guard
  enforces.
- ``forgelm/cli/subcommands/_purge.py:1056`` (S3776, complexity 18 >
  15) — extracted ``_absorb_audit_line`` from ``_build_audit_age_lookup``
  to drop the per-line guard chain out of the file-walk loop.
- ``tools/check_field_descriptions.py:117`` (S3776, complexity 17 >
  15) — extracted ``_scan_field_stmt`` from ``_scan_class`` so the
  per-stmt branch chain lives in a single-purpose helper.
- ``tests/test_faz27_narrow_exceptions.py:52`` (S1481, unused local
  ``langdetect``) — bind the importorskip result and consume it via
  a ``hasattr`` sanity check (also documents the API surface the
  downstream consumer relies on).

Validation: ``ruff format`` clean, ``ruff check`` clean, ``pytest``
1245 passed / 14 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
forgelm/config.py (1)

930-1030: ⚡ Quick win

SonarCloud quality gate failure: extract helper to reduce cognitive complexity to ≤15.

Cognitive complexity of _reconcile_staging_ttl_days exceeds the SonarCloud S3776 threshold (15). The five-branch state machine is clearly structured, but the alias-forwarding branch (lines 991–1010) and the comparison branch (lines 1011–1029) can be extracted without changing behaviour.

♻️ Refactoring sketch — extract `_emit_legacy_alias_forward_warning`
+    `@staticmethod`
+    def _emit_legacy_alias_forward_warning() -> None:
+        warnings.warn(
+            "`evaluation.staging_ttl_days` is deprecated and forwards to "
+            "`retention.staging_ttl_days` for the v0.5.5 → v0.6.x window. "
+            "Move the value under the new top-level `retention:` block; the "
+            "deprecated field is removed in v0.7.0.",
+            DeprecationWarning,
+            stacklevel=4,
+        )
+
     def _reconcile_staging_ttl_days(self) -> None:
         ...
         if legacy is not None and canonical is None:
-            if retention is not None:
-                self.retention = retention.model_copy(update={"staging_ttl_days": legacy})
-            else:
-                self.retention = RetentionConfig(staging_ttl_days=legacy)
-            warnings.warn(...)
+            self.retention = (
+                retention.model_copy(update={"staging_ttl_days": legacy})
+                if retention is not None
+                else RetentionConfig(staging_ttl_days=legacy)
+            )
+            self._emit_legacy_alias_forward_warning()
             return

(Note: stacklevel=4 is required in the extracted method to preserve the original warning attribution to the caller of _reconcile_staging_ttl_days.)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/config.py` around lines 930 - 1030, The method
_reconcile_staging_ttl_days is too complex; extract the alias-forwarding branch
and the "both set and equal" warning branch into small helpers to reduce
cognitive complexity. Create e.g. _apply_legacy_alias_forward(self, legacy,
retention) which updates self.retention using retention.model_copy(update=...)
or RetentionConfig(...) and emits the DeprecationWarning with stacklevel=4, and
_emit_legacy_match_warning(self) which emits the DeprecationWarning (also
stacklevel=4). Replace the inline blocks in _reconcile_staging_ttl_days with
calls to these helpers, keeping all existing messages, use the existing symbols
(RetentionConfig, self.retention, legacy, canonical) and preserve behavior and
return paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@forgelm/cli/subcommands/_safety_eval.py`:
- Around line 131-136: The ImportError handler currently tells users to "pip
install forgelm", which is misleading because transformers is a required base
dependency; update the except ImportError block that calls
_output_error_and_exit (in _safety_eval.py) to provide an actionable hint:
report the actual exception (exc) and suggest verifying the current installation
and environment (e.g., ensure the package was installed into the active
virtualenv or container) and, if needed, reinstall or explicitly install the
missing package with a command such as "pip install transformers" or "pip
install --force-reinstall forgelm", instead of instructing only "pip install
forgelm". Ensure the message remains concise and includes the exception text.

---

Nitpick comments:
In `@forgelm/config.py`:
- Around line 930-1030: The method _reconcile_staging_ttl_days is too complex;
extract the alias-forwarding branch and the "both set and equal" warning branch
into small helpers to reduce cognitive complexity. Create e.g.
_apply_legacy_alias_forward(self, legacy, retention) which updates
self.retention using retention.model_copy(update=...) or RetentionConfig(...)
and emits the DeprecationWarning with stacklevel=4, and
_emit_legacy_match_warning(self) which emits the DeprecationWarning (also
stacklevel=4). Replace the inline blocks in _reconcile_staging_ttl_days with
calls to these helpers, keeping all existing messages, use the existing symbols
(RetentionConfig, self.retention, legacy, canonical) and preserve behavior and
return paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 016bb4fd-17b6-4c72-9bca-21417a18310e

📥 Commits

Reviewing files that changed from the base of the PR and between 51adb42 and dda185e.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • docs/analysis/code_reviews/gdpr-erasure-design-202605021414.md
  • docs/guides/gdpr_erasure-tr.md
  • docs/guides/gdpr_erasure.md
  • forgelm/cli/subcommands/_cache.py
  • forgelm/cli/subcommands/_purge.py
  • forgelm/cli/subcommands/_safety_eval.py
  • forgelm/compliance.py
  • forgelm/config.py
  • tests/test_faz27_narrow_exceptions.py
  • tests/test_grpo_reward.py
  • tools/check_field_descriptions.py
✅ Files skipped from review due to trivial changes (2)
  • tests/test_faz27_narrow_exceptions.py
  • forgelm/cli/subcommands/_purge.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • tests/test_grpo_reward.py
  • forgelm/compliance.py
  • tools/check_field_descriptions.py
  • forgelm/cli/subcommands/_cache.py

Comment thread forgelm/cli/subcommands/_safety_eval.py
- ``forgelm/cli/subcommands/_safety_eval.py`` — ``transformers``
  ImportError handler now reports the actual exception text and
  suggests environment verification (`python -c 'import
  transformers'`) plus a reinstall command.  The previous "pip install
  forgelm" hint was misleading because ``transformers`` is a *core*
  dependency, not an optional extra; if the import fails, the
  operator's environment is broken (wrong virtualenv / corrupted
  install) and a fresh ``pip install forgelm`` would re-pull the same
  broken set.
- ``forgelm/config.py`` — extracted ``_apply_legacy_alias_forward``
  and ``_emit_legacy_match_warning`` from
  ``_reconcile_staging_ttl_days`` so the validator method stays under
  the SonarCloud cognitive-complexity ceiling.  Behaviour preserved:
  ``model_copy(update=...)`` still mirrors the alias onto the
  canonical block while preserving any other ``retention.*`` fields,
  and both DeprecationWarnings now use ``stacklevel=4`` so the
  warning surfaces at the operator's ``ForgeConfig(...)`` call frame
  rather than inside the Pydantic ``@model_validator`` machinery.

Validation: ``ruff format`` clean, ``ruff check`` clean, ``pytest``
1245 passed / 14 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
forgelm/config.py (1)

1008-1049: 💤 Low value

Add a unit test to verify stacklevel=4 points the warning at the operator's call site, not Pydantic internals.

Existing tests in tests/test_gdpr_erasure.py verify that the DeprecationWarning is raised with the correct message, but they don't check where the warning points (filename:lineno). The call stack from warnings.warn() upwards includes Pydantic's validator dispatch layer, whose depth may vary across Pydantic versions. A test asserting warning.filename resolves to the test file (not pydantic/...) would pin the stacklevel down and catch future regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/config.py` around lines 1008 - 1049, Add a unit test that
instantiates ForgeConfig (triggering _reconcile_staging_ttl_days →
_apply_legacy_alias_forward or _emit_legacy_match_warning) inside a
warnings.catch_warnings(record=True) block with warnings.simplefilter("always"),
capture the emitted DeprecationWarning(s), and assert the warning message
matches the expected text and that warning.filename (or str(w.filename)) refers
to the test file (i.e. does not contain "pydantic" or other third-party paths)
to ensure stacklevel=4 surfaces at the operator call site; reference
_apply_legacy_alias_forward and _emit_legacy_match_warning in the test so it
covers both forward and match-warning paths.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@forgelm/config.py`:
- Around line 1008-1049: Add a unit test that instantiates ForgeConfig
(triggering _reconcile_staging_ttl_days → _apply_legacy_alias_forward or
_emit_legacy_match_warning) inside a warnings.catch_warnings(record=True) block
with warnings.simplefilter("always"), capture the emitted DeprecationWarning(s),
and assert the warning message matches the expected text and that
warning.filename (or str(w.filename)) refers to the test file (i.e. does not
contain "pydantic" or other third-party paths) to ensure stacklevel=4 surfaces
at the operator call site; reference _apply_legacy_alias_forward and
_emit_legacy_match_warning in the test so it covers both forward and
match-warning paths.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 65e86624-7ce0-4c11-8ac4-bad9463d6764

📥 Commits

Reviewing files that changed from the base of the PR and between dda185e and 43f30c2.

📒 Files selected for processing (2)
  • forgelm/cli/subcommands/_safety_eval.py
  • forgelm/config.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • forgelm/cli/subcommands/_safety_eval.py

cemililik and others added 4 commits May 5, 2026 10:33
- ``tests/test_gdpr_erasure.py`` — new
  ``test_deprecation_warnings_attribute_to_operator_call_site``
  exercises both deprecation paths
  (``_apply_legacy_alias_forward`` for legacy-only,
  ``_emit_legacy_match_warning`` for both-set-equal) inside a
  ``warnings.catch_warnings(record=True)`` block and asserts that
  the captured ``WarningMessage.filename`` is this test file (not a
  path under ``pydantic/``).  Locks in the stacklevel tuning so a
  future Pydantic upgrade that changes the validator wrapper depth
  fails the test before the misleading attribution leaks into
  operators' logs.
- ``forgelm/config.py`` — bumped both deprecation warnings from
  ``stacklevel=4`` to ``stacklevel=5``.  Empirically Pydantic v2's
  ``@model_validator(mode="after")`` wrapper adds one more frame
  than I'd budgeted, so stacklevel=4 was landing at
  ``pydantic/main.py:<init line>`` instead of the operator's
  ``ForgeConfig(...)`` call site.  Confirmed by the new regression
  test.

Validation: ``ruff format`` clean, ``ruff check`` clean, ``pytest``
1246 passed / 14 skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Self-contained multi-agent review prompt targeting Wave 2b's five
phases (16, 19, 21, 35, 36) at HEAD ``36afbc0``.  Tuned for a
ship-or-block decision after five rounds of absorption have already
landed:

- Areas-to-scrutinise section is keyed off the surfaces where
  Rounds 1-5 made the biggest changes (atomic rewrite ordering,
  per-run audit-age lookup, Annex IV writer/verifier round-trip,
  Hub vs. Datasets cache resolver split, RetentionConfig
  ``model_copy(update=...)`` preservation).
- Severity bar narrowed for the final round: ``CRITICAL`` /
  ``HIGH`` reserved for genuine merge blockers; ``LOW`` / ``NIT``
  items will be tracked as follow-up issues, not absorbed.
- Cross-phase checks XPR-01 through XPR-09 cover audit-event
  vocabulary stability, exit-code contract, JSON envelope schema,
  doc/code drift, vacuous tests, webhook-URL redaction, Sonar
  gate, backwards compatibility, and ``model_copy``
  deprecation-forward call sites.
- Required deliverable structure includes a per-agent
  ``Merge recommendation`` paragraph so the maintainer can compare
  agents head-to-head.

Mirrors the format of the Wave 2a Round-2 prompt
(``wave2a-round2-review-prompt.md``); same skeleton so prior
reviewers can pick up cold.  ``.gitignore`` allowlist extended to
re-include the new prompt under the otherwise-internal
``docs/analysis/code_reviews/`` tree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final pre-merge absorption of the multi-agent review on PR #30.
Skips 12 LOW/NIT items with documented reasons; absorbs 24 across
tests, code, and docs.

CODE EDITS (5):

- _safety_eval.py F-36-02: transformers ImportError -> EXIT_TRAINING_ERROR (2)
- _cache.py F-XPR-01: huggingface_hub ImportError -> EXIT_TRAINING_ERROR (2)
- _cache.py F-35-01: HF_DATASETS_CACHE try/finally restore (no env leak)
- _purge.py F-XPR-02: _maybe_load_config narrow exception tuple
- __init__.py F-19-02: __dir__ filters single-underscore privates

TEST ADDITIONS (52 new tests, suite 1246 -> 1298):

- test_gdpr_erasure.py +11: F-21-T-01..07 (truncated salt, prefix
  matcher, per-run audit-age x3, --check-policy strict-load x3,
  model_copy preservation, fsync fd pinning)
- test_cache_subcommands.py +10: F-35-T-01..03 (Hub/Datasets
  resolver split + cross-bleed, env stamp + leak defence + restore,
  mid-batch failure ordering)
- test_verification_toolbelt.py +11: F-36-T-01 (safety-eval exit-3),
  F-36-03 (parametrised tampering x10)
- test_library_api.py +4: F-19-T-01 (forbid forgelm.* submodules),
  F-19-T-02 (__dir__ no imports), F-19-02 dir-leak regression,
  __api_version__ rule pin
- test_check_field_descriptions.py NEW (17): F-16-01 scanner own
  test suite (recognition branches, model_config skip, multi-class
  discrimination, --strict CLI exit codes, canonical config gate)

DOC EDITS (4):

- json-output.md (EN+TR) F-XPR-04 (HIGH): 6 new subcommand sections
  with success-envelope schemas + per-key tables + exit-code maps
- safety.md (EN+TR) F-XPR-04b: "200-prompt" -> "50-prompt" reconciled
  with bundled default_probes.jsonl + parser --help
- reference/cli.md (EN+TR) F-XPR-04c: 6 rows added to subcommand table
- cut-release SKILL + release.md F-19-01: __api_version__ bump policy
  documented (Step 3.5 + parallel standard section)

SKIPPED (12 documented):

- F-21-04, F-21-05 (defensive only / pre-existing pattern)
- F-36-04 (design preference, documented contract)
- F-XPR-04d, F-XPR-01-stds (pre-existing in main, OOS)
- F-XPR-T-04 (release-time manual)
- F-XPR-T-03 (cosmetic rename)
- F-16-02 (extension hook intentional)
- F-XPR-T-01, F-XPR-T-02 (covered by dispatcher tests / no-finding)
- F-36-T-02, F-36-T-03 (minor edge / covered transitively)

VALIDATION

- ruff format clean
- ruff check clean
- pytest 1298 passed / 14 skipped (+52 new)
- forgelm --dry-run config_template.yaml green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
10 faz status update:

Wave 2a (PR #28, merged 2026-05-04):
- Faz 17 (audit --workers N) ⏳ → ✅
- Faz 18 (Library API design) ⏳ → ✅
- Faz 20 (GDPR design) ⏳ → ✅
- Faz 34 (forgelm doctor) ⏳ → ✅
- Faz 37 (forgelm approvals) ⏳ → ✅

Wave 2b (PR #30, ready to merge 2026-05-05):
- Faz 16 (Pydantic description= migration + AST guard) ⏳ → ✅
- Faz 19 (Library API impl: lazy facade + py.typed) ⏳ → ✅
- Faz 21 (GDPR purge + RetentionConfig + 6 audit events) ⏳ → ✅
- Faz 35 (air-gap cache-models / cache-tasks) ⏳ → ✅
- Faz 36 (verify-annex-iv / verify-gguf / safety-eval) ⏳ → ✅

Plus: status legend entries for Wave 2a + 2b, kapanış özet
paragraflarının eklenmesi, version log v2.6 (Wave 2a) + v2.7
(Wave 2b) entry'leri.

Kalan iş (Wave 3+): 7 faz + 1 release.
- Faz 22 (ISO 27001/SOC 2 design)
- Faz 23 (ISO 27001/SOC 2 impl)
- Faz 24 (bilingual TR mirror sweep)
- Faz 26 (QMS bilingual mirror)
- Faz 28 (Major+Minor cleanup)
- Faz 30 (final docs + site finalization)
- Faz 38 (forgelm reverse-pii GDPR Art. 15)
- Faz 33 (v0.5.5 PyPI publish — POST-WAVE)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.claude/skills/cut-release/SKILL.md:
- Around line 45-47: Remove the stale sentence that states `__version__` is the
single source of truth (the duplicate line "Current version in `pyproject.toml`
line 7. Single source of truth.") and replace it with a single consistent
statement that `__version__` and `__api_version__` each have their own source of
truth (i.e., `__version__` in pyproject.toml and `__api_version__` in
forgelm/_version.py), ensuring the doc no longer contradicts the dual-contract
guidance; edit the SKILL.md paragraph containing `__version__` and
`__api_version__` to remove the conflicting sentence and keep one clear line
describing each variable's source of truth.
- Line 43: The docs currently advise using string comparison like assert
forgelm.__api_version__ >= "X.Y.0", which is incorrect for semantic versions;
update the three spots that recommend that exact assertion to instruct using
packaging.version.Version parsing instead: show importing Version from
packaging.version and using assert Version(forgelm.__api_version__) >=
Version("X.Y.0") for feature detection (i.e., replace the string-comparison
example with the Version(...) comparison and a short note to import
packaging.version.Version).

In `@docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md`:
- Line 19: The PR base branch is wrongly stated as `main` in the line "**PR:**
[`#30`](https://github.com/cemililik/ForgeLM/pull/30) → base `main`"; update that
text to read `development` so it matches the PR objectives
("closure/wave2b-integration → development"). Edit the line in the
docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md file to replace
`main` with `development` (keeping the PR link and formatting intact) so
reviewers see the correct target branch.

In `@docs/usermanuals/en/reference/json-output.md`:
- Around line 379-383: Update the description of checks.metadata_parsed so it no
longer implies that a missing optional parser makes the whole artifact invalid:
change the prose for `checks.metadata_parsed` to state it is true when the
`gguf` metadata block was successfully parsed, and false when the metadata block
is corrupted or when the optional `gguf` package is absent/skipped (and
explicitly note that a false `checks.metadata_parsed` does not by itself force
`valid` to be false).
- Around line 176-235: The docs and implementation diverge: update
forgelm/cli/subcommands/_purge.py to emit the documented JSON envelope shape —
in row mode replace the current "matches" and "first_line" keys with
"match_count" and "pre_erasure_line_number" and ensure "row_id_hash" is a
sha256: prefixed hex digest (never plain 64-hex); in run mode replace
"would_delete"/"deleted" with "files_modified" (list) and "bytes_freed" (int)
and include "run_id" and "kind"; for --check-policy ensure the success response
uses "violations" (list of objects with
artefact_kind/path/age_days/horizon_days/age_source) and "count" ==
len(violations) (instead of emitting "note" only), and always include "success"
and "dry_run" where applicable; locate and modify the response-building code
(functions/variables in _purge.py that assemble the envelope dict) to rename
keys, add missing fields (mode, salt_source, corpus_path when applicable), and
format row_id_hash with the "sha256:" prefix before JSON serialization.

In `@docs/usermanuals/tr/reference/json-output.md`:
- Around line 379-384: The documentation currently implies that `valid: true`
requires `checks.metadata_parsed` to be true; update the `valid` field
description so it explicitly states that `valid: true` means the magic
header/metadata block/sidecar (if present) passed verification but does not
require `checks.metadata_parsed` to be true (i.e., metadata may be skipped or
unparsed yet overall validation can still be true per tests), and make the
`checks.metadata_parsed` entry remain as-is to indicate parsing status; mention
that tests (e.g., `tests/test_verification_toolbelt.py`) allow
`checks.metadata_parsed == false` while `valid == true`.
- Around line 176-235: The docs JSON schema in docs/usermanuals/... does not
match the runtime payload emitted by the purge implementation in _purge.py;
update the documented examples and key table to exactly match the actual keys:
in row mode use "matches" and "first_line" (not
"match_count"/"pre_erasure_line_number") and document that "row_id_hash" is a
plain 64-hex string (no "sha256:" prefix); in run mode list "would_delete" /
"deleted" instead of "files_modified"; for --check-policy show that successful
responses include "note" and may omit a "retention" block and that "count" may
be absent (or set to len(violations) only if implementation emits it) — adjust
the table rows for `violations`, `count`, and `dry_run` accordingly and ensure
examples and descriptions reference the actual keys produced by _purge.py and
the --check-policy flag.

In `@forgelm/cli/subcommands/_purge.py`:
- Around line 757-765: _staging_targets_for_run currently always includes the
unscoped legacy path "final_model.staging" which allows purging unrelated runs;
update _staging_targets_for_run to only include base/"final_model.staging" when
ownership can be proved (e.g. inspect a marker/metadata file inside
final_model.staging and verify it contains the given run_id) and otherwise skip
that candidate (or require an explicit opt‑in/force flag), leaving the explicit
scoped path base/"final_model.staging.<run_id>" unchanged.

In `@tests/test_cache_subcommands.py`:
- Around line 460-487: Update the two tests that currently use plain with
pytest.raises(SystemExit): to capture the exception context and assert a
successful exit code; specifically wrap the _cache._run_cache_tasks_cmd call
with with pytest.raises(SystemExit) as ei: and after the call add assert
ei.value.code == 0. Apply this change in both tests shown (the one that checks
HF_DATASETS_CACHE is not leaked and
test_cache_tasks_restores_prior_hf_datasets_cache_env) so they validate the
happy-path exit rather than any SystemExit.

In `@tests/test_check_field_descriptions.py`:
- Around line 180-184: Update the regression comment that contradicts the test
expectation: change the wording around _annotation_has_described_field and
"Field-in-Annotated" to state that a bare Field inside typing.Annotated without
a description is detected/flagged (the test asserts len(missing) == 1), not
"missed"; reference the existing symbols _annotation_has_described_field and
Field-in-Annotated so future readers know the comment applies to that behavior
and note that if future refactors change recursion into kwargs the assertion
should be updated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 60f44696-4f26-4a34-8d38-400c703a94f7

📥 Commits

Reviewing files that changed from the base of the PR and between dda185e and 55a8920.

📒 Files selected for processing (20)
  • .claude/skills/cut-release/SKILL.md
  • .gitignore
  • docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md
  • docs/standards/release.md
  • docs/usermanuals/en/evaluation/safety.md
  • docs/usermanuals/en/reference/cli.md
  • docs/usermanuals/en/reference/json-output.md
  • docs/usermanuals/tr/evaluation/safety.md
  • docs/usermanuals/tr/reference/cli.md
  • docs/usermanuals/tr/reference/json-output.md
  • forgelm/__init__.py
  • forgelm/cli/subcommands/_cache.py
  • forgelm/cli/subcommands/_purge.py
  • forgelm/cli/subcommands/_safety_eval.py
  • forgelm/config.py
  • tests/test_cache_subcommands.py
  • tests/test_check_field_descriptions.py
  • tests/test_gdpr_erasure.py
  • tests/test_library_api.py
  • tests/test_verification_toolbelt.py
✅ Files skipped from review due to trivial changes (5)
  • docs/usermanuals/en/reference/cli.md
  • docs/standards/release.md
  • .gitignore
  • docs/usermanuals/tr/reference/cli.md
  • forgelm/cli/subcommands/_safety_eval.py
🚧 Files skipped from review as they are similar to previous changes (4)
  • forgelm/cli/subcommands/_cache.py
  • tests/test_library_api.py
  • forgelm/init.py
  • forgelm/config.py

Comment thread .claude/skills/cut-release/SKILL.md Outdated
| Stable library symbol added to `forgelm.__all__` (e.g. new `verify_*` function) | MINOR | MINOR |
| Stable library symbol removed or signature changed | MAJOR | MAJOR |

`__version__` (in [`pyproject.toml`](../../../pyproject.toml) line 7) tracks the *CLI / YAML / behavioural* contract. `__api_version__` (in [`forgelm/_version.py`](../../../forgelm/_version.py)) tracks the *Python library* contract — operators who pin `assert forgelm.__api_version__ >= "X.Y.0"` for feature detection rely on this signal. The two version numbers are decoupled by design: a release that adds a new training paradigm bumps `__version__` MINOR while leaving `__api_version__` untouched (no new public symbol), and a release that adds a new lazy-import target bumps both.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify locations where direct string comparison guidance appears
rg -n -C2 '__api_version__\s*>=' .claude/skills/cut-release/SKILL.md docs/standards/release.md

Repository: cemililik/ForgeLM

Length of output: 2460


🏁 Script executed:

#!/bin/bash
# Check if packaging library is already used in the codebase
rg -l "packaging" --type py

# Also check what version comparison patterns exist in actual code
rg "from.*version|import.*version" --type py forgelm/ | head -20

Repository: cemililik/ForgeLM

Length of output: 1163


🏁 Script executed:

#!/bin/bash
# Check docs/standards/release.md line 39 context
sed -n '35,42p' docs/standards/release.md

Repository: cemililik/ForgeLM

Length of output: 723


🏁 Script executed:

#!/bin/bash
# Check if __api_version__ is actually used anywhere with comparisons
rg "__api_version__" --type py -A 2 -B 2 forgelm/

Repository: cemililik/ForgeLM

Length of output: 1876


🏁 Script executed:

#!/bin/bash
# Demonstrate the lexicographic comparison issue
python3 <<'EOF'
# Show the problem with string comparison for semver
test_cases = [
    ("1.10.0", "1.2.0"),
    ("2.0.0", "10.0.0"),
    ("1.9.9", "1.10.0"),
]

print("String comparison (WRONG for semver):")
for v1, v2 in test_cases:
    result = v1 >= v2
    print(f'  "{v1}" >= "{v2}": {result}')

print("\nParsed comparison (correct for semver):")
try:
    from packaging.version import Version
    for v1, v2 in test_cases:
        result = Version(v1) >= Version(v2)
        print(f'  Version("{v1}") >= Version("{v2}"): {result}')
except ImportError:
    print("  packaging not installed, but string comparison clearly fails")
EOF

Repository: cemililik/ForgeLM

Length of output: 365


Use semantic version parsing instead of string comparison for feature detection guidance

The current guidance to use assert forgelm.__api_version__ >= "X.Y.0" for version checks is incorrect. Python string comparison is lexicographic, not semantic-aware: "1.10.0" >= "1.2.0" evaluates to False (wrong for semver). Operators following this guidance will have broken feature detection.

Update three locations (.claude/skills/cut-release/SKILL.md lines 43 and 103; docs/standards/release.md line 39) to recommend packaging.version.Version parsing:

Proposed doc fix
- operators who pin `assert forgelm.__api_version__ >= "X.Y.0"` for feature detection
+ operators should parse versions for feature detection: `from packaging.version import Version; assert Version(forgelm.__api_version__) >= Version("X.Y.0")`

- Library consumers can then pin `assert forgelm.__api_version__ >= "X.Y.0"` for feature detection.
+ Library consumers can then gate on parsed versions: `from packaging.version import Version; assert Version(forgelm.__api_version__) >= Version("X.Y.0")`.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/skills/cut-release/SKILL.md at line 43, The docs currently advise
using string comparison like assert forgelm.__api_version__ >= "X.Y.0", which is
incorrect for semantic versions; update the three spots that recommend that
exact assertion to instruct using packaging.version.Version parsing instead:
show importing Version from packaging.version and using assert
Version(forgelm.__api_version__) >= Version("X.Y.0") for feature detection
(i.e., replace the string-comparison example with the Version(...) comparison
and a short note to import packaging.version.Version).

Comment thread .claude/skills/cut-release/SKILL.md Outdated
Comment thread docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md Outdated
Comment on lines +176 to +235
**Row-erasure success envelope** (`forgelm purge --row-id ROW --corpus PATH`):

```json
{
"success": true,
"mode": "row",
"row_id_hash": "sha256:abc123...",
"salt_source": "per_dir",
"corpus_path": "/work/train.jsonl",
"files_modified": ["/work/train.jsonl"],
"bytes_freed": 142,
"pre_erasure_line_number": 42,
"match_count": 1,
"dry_run": false
}
```

**Run-erasure success envelope** (`--run-id RUN --kind {staging,artefacts}`):

```json
{
"success": true,
"mode": "run",
"run_id": "fg-abc123",
"kind": "staging",
"files_modified": ["/work/output/final_model.staging.fg-abc123"],
"bytes_freed": 1048576,
"dry_run": false
}
```

**Check-policy success envelope** (`--check-policy [--config PATH]`):

```json
{
"success": true,
"violations": [
{
"artefact_kind": "staging_dir[fg-abc123]",
"path": "/work/output/final_model.staging.fg-abc123",
"age_days": 14.7,
"horizon_days": 7,
"age_source": "audit"
}
],
"count": 1
}
```

| Key | Type | Notes |
|---|---|---|
| `success` | bool | `true` for a successful operation; `false` (with `error`) on config / runtime failure. |
| `mode` (row/run) | str | Discriminator; `"row"` or `"run"`. Absent on `--check-policy`. |
| `row_id_hash` | str | `sha256:`-prefixed hex digest of the salted row id. Cleartext value never appears in the envelope. |
| `salt_source` | str | `"per_dir"` or `"env_var"` per `FORGELM_AUDIT_SECRET` toggle. |
| `violations` | list[object] | `--check-policy` only. `artefact_kind` is one of `audit_log`, `staging_dir`, `staging_dir[<run_id>]`, `compliance_bundle`, `data_audit_report`, `raw_documents[...]`. `age_source` ∈ `{audit, mtime}`. |
| `count` | int | `--check-policy` only; equals `len(violations)`. |
| `dry_run` | bool | Mirrors the `--dry-run` flag. |

**Exit code mapping:** `0` = success or successful policy report; `1` = config error (unknown row, missing corpus, conflicting flags, malformed `--check-policy --config`); `2` = runtime error (I/O, atomic rename failure).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

The documented purge envelopes don't match the implementation.

forgelm/cli/subcommands/_purge.py currently emits a different shape here: row mode uses matches / first_line and a plain 64-hex row_id_hash, run mode uses would_delete / deleted, and the successful --check-policy path without a retention: block includes note while omitting count. Because this page is presented as the canonical JSON contract, CI consumers pinning to it will parse the wrong schema.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/usermanuals/en/reference/json-output.md` around lines 176 - 235, The
docs and implementation diverge: update forgelm/cli/subcommands/_purge.py to
emit the documented JSON envelope shape — in row mode replace the current
"matches" and "first_line" keys with "match_count" and "pre_erasure_line_number"
and ensure "row_id_hash" is a sha256: prefixed hex digest (never plain 64-hex);
in run mode replace "would_delete"/"deleted" with "files_modified" (list) and
"bytes_freed" (int) and include "run_id" and "kind"; for --check-policy ensure
the success response uses "violations" (list of objects with
artefact_kind/path/age_days/horizon_days/age_source) and "count" ==
len(violations) (instead of emitting "note" only), and always include "success"
and "dry_run" where applicable; locate and modify the response-building code
(functions/variables in _purge.py that assemble the envelope dict) to rename
keys, add missing fields (mode, salt_source, corpus_path when applicable), and
format row_id_hash with the "sha256:" prefix before JSON serialization.

Comment thread docs/usermanuals/en/reference/json-output.md Outdated
Comment thread docs/usermanuals/tr/reference/json-output.md Outdated
Comment thread docs/usermanuals/tr/reference/json-output.md Outdated
Comment thread forgelm/cli/subcommands/_purge.py Outdated
Comment thread tests/test_cache_subcommands.py
Comment thread tests/test_check_field_descriptions.py Outdated
Code (1):

- _purge.py F-21-STAGING: _staging_targets_for_run no longer
  unconditionally includes the unscoped legacy "final_model.staging/"
  path.  The legacy candidate is now only purged when a marker file
  "staging_run_id" inside the directory matches the operator's
  --run-id; absent / unreadable / mismatching markers cause the
  legacy candidate to be skipped (operators with v0.5.0-or-earlier
  workspaces remove the dir manually).  Defends against silent
  cross-run staging deletion.
- _purge.py: row-mode JSON envelopes now carry "corpus_path"
  (additive; no key renames).  _emit_row_dry_run signature gains
  corpus_path: str so the dry-run path doesn't bleed args into
  helpers that don't take it.

Docs (5):

- .claude/skills/cut-release/SKILL.md F-19-DOC: dropped the
  duplicate "Single source of truth" sentence; replaced
  string-comparison example with packaging.version.Version-based
  comparison (avoids the lexicographic "1.10.0" < "1.2.0" pitfall).
  Mirrored the same change at the Step-3.5 checklist row.
- docs/standards/release.md: same Version() comparison block.
- docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md:
  PR base branch corrected from "main" to "development" (PR #30
  actually targets development; gh pr view confirms).  Goal
  paragraph mirrored.
- docs/usermanuals/{en,tr}/reference/json-output.md verify-gguf
  table: clarified that checks.metadata_parsed=false does NOT
  force valid=false when the optional gguf package is absent;
  only an attempted-and-failed check (corruption) rejects.  Cited
  tests/test_verification_toolbelt.py as the contract pin.
- docs/usermanuals/{en,tr}/reference/json-output.md purge schema:
  rewrote the EN + TR sections to match the actual _purge.py
  envelopes (matches/first_line/would_delete/deleted) instead of
  the aspirational shape (match_count/pre_erasure_line_number/
  files_modified) that the Wave 2b absorption commit had
  documented.  Documented that row_id_hash is plain 64-hex (no
  "sha256:" prefix), the dry-vs-wet shape difference for run mode,
  and the no-retention-block branch's "note" key in
  --check-policy.  Reality > aspiration.

Tests (2):

- tests/test_cache_subcommands.py: two HF_DATASETS_CACHE leak /
  restore tests now assert ei.value.code == 0 so the leak-defence
  claim is anchored on the happy path (a code-1 / code-2
  early-exit that never reaches the env-stamp restore would
  otherwise silently pass).
- tests/test_check_field_descriptions.py: the stale comment in
  test_annotated_field_without_description_flagged claimed bare
  Field-in-Annotated without description IS missed; the actual
  assertion (and shipped contract) is that it IS flagged.
  Rewrote the comment to match the assertion + cite the scanner
  helper.

SKIPPED (1):

- _purge.py rename "matches"/"first_line" → "match_count"/
  "pre_erasure_line_number" + "would_delete"/"deleted" →
  "files_modified" + "sha256:" prefix on row_id_hash.  Renaming
  the JSON envelope wire contract immediately after Wave 2b ships
  would break consumers; the doc-direction (fix docs to match
  impl) is the right call.  Additive corpus_path enhancement
  applied instead.

VALIDATION

- ruff format clean
- ruff check clean
- pytest 1298 passed / 14 skipped
- forgelm --dry-run config_template.yaml green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/test_check_field_descriptions.py (2)

233-260: ⚡ Quick win

Expand CLI exit-code contract tests beyond 0/1 (Line 233-Line 260).

These tests lock strict/non-strict success semantics well, but they don’t pin the remaining public exit-code paths. Add targeted cases for the other contract codes so CI behavior can’t drift silently.

As per coding guidelines, “Error handling must follow docs/standards/error-handling.md standards including exit codes (0/1/2/3/4) as public contracts for CI/CD pipelines.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_check_field_descriptions.py` around lines 233 - 260, Add tests to
assert the full public exit-code contract (0/1/2/3/4) by expanding
TestCliStrictMode: create three new test methods that call
check_field_descriptions.main and assert expected codes for error scenarios
beyond strict/non-strict: (1) a test that passes an invalid/unknown CLI flag to
produce the argument/usage error and assert return code 2, (2) a test that
supplies a non-existent file path (or unreadable file) to simulate a CLI I/O or
validation failure and assert return code 3, and (3) a test that forces an
internal failure (e.g., monkeypatch/patch a helper used by
check_field_descriptions.main to raise an unexpected Exception) and assert
return code 4; reference the check_field_descriptions.main entrypoint and the
TestCliStrictMode test class/ tmp_path fixture when adding these new test
methods so CI asserts all public exit-code paths.

23-30: ⚡ Quick win

Align import pattern with existing project guidance by using importlib.util (Line 26-29).

Tests in this repository already follow a pattern of importing tools without modifying sys.path (see tests/test_check_site_claims.py line 28, which states "Import tools/check_site_claims.py as a module without modifying sys.path"). The current code mutates the global sys.path, which contradicts the established project pattern and the explicit guidance in test_check_site_claims.py that the "test suite stays decoupled from any sys.path manipulation" (line 12). Use importlib.util.spec_from_file_location to match the existing pattern and align with Python best practices.

Proposed patch
-from __future__ import annotations
-
-import sys
+from __future__ import annotations
+
+import importlib.util
 from pathlib import Path
@@
-# Import the scanner via its file path — avoids a project-side
-# ``setup.py`` install just to make ``tools/`` importable.
-_TOOLS_DIR = Path(__file__).parent.parent / "tools"
-if str(_TOOLS_DIR) not in sys.path:
-    sys.path.insert(0, str(_TOOLS_DIR))
-
-import check_field_descriptions  # noqa: E402 — depends on the path patch above.
+# Import scanner directly from file path without modifying sys.path.
+_TOOLS_FILE = Path(__file__).parent.parent / "tools" / "check_field_descriptions.py"
+_SPEC = importlib.util.spec_from_file_location("check_field_descriptions", _TOOLS_FILE)
+assert _SPEC is not None and _SPEC.loader is not None
+check_field_descriptions = importlib.util.module_from_spec(_SPEC)
+_SPEC.loader.exec_module(check_field_descriptions)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_check_field_descriptions.py` around lines 23 - 30, Replace the
sys.path mutation import pattern in tests/test_check_field_descriptions.py: stop
inserting _TOOLS_DIR into sys.path and instead load the
tools/check_field_descriptions.py file as a module using
importlib.util.spec_from_file_location and importlib.util.module_from_spec; use
the existing _TOOLS_DIR variable to build the file path and the module name
"check_field_descriptions", call spec.loader.exec_module(spec_module) (or
equivalent) to execute it, and assign the loaded module to the name
check_field_descriptions so the rest of the test can reference it without
altering sys.path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md`:
- Line 21: Replace residual references to "main" with "development" in the
document so the base branch is consistent: update the diff summary line that
currently reads "**Diff scope vs main:**" to "**Diff scope vs development:**",
change the phrase "will be merged to `main`" to "will be merged to
`development`", and scan for any other occurrences of "vs main" or "`main`"
(notably the block around the summary and the paragraph near "merged to `main`")
to ensure all mentions match the PR objective (closure/wave2b-integration →
development).

In `@forgelm/cli/subcommands/_purge.py`:
- Around line 235-279: The atomic rewrite in _atomic_rewrite_dropping_lines
currently flushes and fsyncs the temp file but omits fsyncing the parent
directory after os.replace, so add a directory fsync immediately after
os.replace(tmp_path, corpus_path): open the parent directory (using os.open with
O_DIRECTORY), call os.fsync on that directory FD, then close it (ensuring this
is in a try/finally or similar to always close); keep the existing temp-file
cleanup/exception behavior, and apply the same directory-fsync change in
forgelm/data_audit/_orchestrator.py::_atomic_write_json.

---

Nitpick comments:
In `@tests/test_check_field_descriptions.py`:
- Around line 233-260: Add tests to assert the full public exit-code contract
(0/1/2/3/4) by expanding TestCliStrictMode: create three new test methods that
call check_field_descriptions.main and assert expected codes for error scenarios
beyond strict/non-strict: (1) a test that passes an invalid/unknown CLI flag to
produce the argument/usage error and assert return code 2, (2) a test that
supplies a non-existent file path (or unreadable file) to simulate a CLI I/O or
validation failure and assert return code 3, and (3) a test that forces an
internal failure (e.g., monkeypatch/patch a helper used by
check_field_descriptions.main to raise an unexpected Exception) and assert
return code 4; reference the check_field_descriptions.main entrypoint and the
TestCliStrictMode test class/ tmp_path fixture when adding these new test
methods so CI asserts all public exit-code paths.
- Around line 23-30: Replace the sys.path mutation import pattern in
tests/test_check_field_descriptions.py: stop inserting _TOOLS_DIR into sys.path
and instead load the tools/check_field_descriptions.py file as a module using
importlib.util.spec_from_file_location and importlib.util.module_from_spec; use
the existing _TOOLS_DIR variable to build the file path and the module name
"check_field_descriptions", call spec.loader.exec_module(spec_module) (or
equivalent) to execute it, and assign the loaded module to the name
check_field_descriptions so the rest of the test can reference it without
altering sys.path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4c50f3e0-8e59-496b-b062-19875f7462a9

📥 Commits

Reviewing files that changed from the base of the PR and between 55a8920 and 4b592f9.

📒 Files selected for processing (9)
  • .claude/skills/cut-release/SKILL.md
  • docs/analysis/code_reviews/closure-plan-202604300906.md
  • docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md
  • docs/standards/release.md
  • docs/usermanuals/en/reference/json-output.md
  • docs/usermanuals/tr/reference/json-output.md
  • forgelm/cli/subcommands/_purge.py
  • tests/test_cache_subcommands.py
  • tests/test_check_field_descriptions.py
✅ Files skipped from review due to trivial changes (2)
  • docs/analysis/code_reviews/closure-plan-202604300906.md
  • docs/standards/release.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • .claude/skills/cut-release/SKILL.md
  • docs/usermanuals/en/reference/json-output.md
  • tests/test_cache_subcommands.py

- **Branch under review:** `closure/wave2b-integration`
- **PR:** [#30](https://github.com/cemililik/ForgeLM/pull/30) → base `development`
- **HEAD SHA at prompt freeze:** `36afbc0`
- **Diff scope vs main:** 195 files, ~32 300 insertions, ~6 300 deletions.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Residual main references contradict the corrected base branch.

Line 19 was fixed to development in a prior round, but line 21 still describes the diff as "vs main", and line 382 still says the branch will be "merged to main". This contradicts line 10 ("merged to development") and the PR objectives (closure/wave2b-integration → development), and may mislead reviewers about which baseline the ~32 300/~6 300 line counts were computed against.

📝 Proposed fix
-- **Diff scope vs main:** 195 files, ~32 300 insertions, ~6 300 deletions.
+- **Diff scope vs development:** 195 files, ~32 300 insertions, ~6 300 deletions.
 - After this round, surviving findings will be addressed in a single
   final absorption commit (or skipped with a documented reason), and
-  the branch will be merged to `main`. There is no Round-7.
+  the branch will be merged to `development`. There is no Round-7.

Also applies to: 380-383

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md` at line 21,
Replace residual references to "main" with "development" in the document so the
base branch is consistent: update the diff summary line that currently reads
"**Diff scope vs main:**" to "**Diff scope vs development:**", change the phrase
"will be merged to `main`" to "will be merged to `development`", and scan for
any other occurrences of "vs main" or "`main`" (notably the block around the
summary and the paragraph near "merged to `main`") to ensure all mentions match
the PR objective (closure/wave2b-integration → development).

Comment on lines +235 to +279
def _atomic_rewrite_dropping_lines(corpus_path: str, line_numbers_to_drop: List[int]) -> int:
"""Rewrite ``corpus_path`` excluding the given 1-based line numbers.

Uses a temp file in the same directory + ``os.replace`` for atomic
swap — operators get either the full pre-erasure file or the full
post-erasure file, never a partial state. Returns the byte count
freed.

Raises :class:`OSError` on any I/O failure; the caller is responsible
for emitting ``data.erasure_failed`` and surfacing
``EXIT_TRAINING_ERROR``.
"""
drop_set = set(line_numbers_to_drop)
pre_size = os.path.getsize(corpus_path)
parent = os.path.dirname(os.path.abspath(corpus_path)) or "."
fd, tmp_path = tempfile.mkstemp(prefix=".forgelm_purge_", suffix=".tmp", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as out:
with open(corpus_path, "r", encoding="utf-8") as src:
for line_no, line in enumerate(src, start=1):
if line_no in drop_set:
continue
out.write(line)
# Wave 2b Round-4 review F-W2B-03: flush + fsync the temp
# file's data blocks to disk BEFORE the namespace swap.
# ``os.replace`` is rename-atomic on POSIX, but without an
# explicit fsync the temp file's *contents* may still be
# buffered in the page cache. A power loss between the
# swap and the cache flush would leave the corpus inode
# pointing at the new file with its data blocks unwritten
# — i.e. an empty corpus. Mirrors the discipline in
# `forgelm/data_audit/_orchestrator.py::_atomic_write_json`.
out.flush()
os.fsync(out.fileno())
os.replace(tmp_path, corpus_path)
except OSError:
# Best-effort cleanup of the temp file if the swap failed.
if os.path.exists(tmp_path):
try:
os.unlink(tmp_path)
except OSError: # pragma: no cover — defensive
logger.debug("Could not clean up purge temp file %s", tmp_path)
raise
post_size = os.path.getsize(corpus_path)
return pre_size - post_size

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

On POSIX, is fsync on the parent directory required after os.replace/rename to make the rename durable across power loss?

💡 Result:

Yes, on POSIX systems, fsync on the parent directory (or directories, if source and destination parents differ) is required after rename (or os.replace, which uses rename) to make the rename durable across power loss. POSIX rename is atomic with respect to other filesystem operations but provides no durability guarantees against crashes; directory entries are data in the parent directory, so fsync on the file does not persist the name change.[1][2][3][4] The Linux fsync man page explicitly states: "Calling fsync does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync on a file descriptor for the directory is also needed."[2][1] The Austin Group (POSIX maintainers) defect report confirms: "Although directory operations are atomic and serializable, they are not necessarily durable. An application that requires a directory modification to be durable should use fdatasync or fsync on the directory... Two calls to fsync may be needed after a rename to ensure its durability."[3][4] If oldpath and newpath are in the same directory, fsync on that single parent suffices; otherwise, fsync both parents.[5][6]

Citations:


🏁 Script executed:

find . -name "_orchestrator.py" -path "*/data_audit/*" | head -5

Repository: cemililik/ForgeLM

Length of output: 99


🏁 Script executed:

rg -A 30 "def _atomic_write_json" ./forgelm/data_audit/_orchestrator.py

Repository: cemililik/ForgeLM

Length of output: 1456


Parent directory fsync is missing after os.replace to make the rename durable across power loss.

The current discipline (flush() → fsync(temp) → os.replace) guarantees the new file's data blocks reach disk, but on POSIX the rename operation (the directory entry change that flips corpus_path to the temp inode) lives in the parent directory inode and is not made durable until the directory itself is fsynced. After a power loss between the rename and the directory cache flush, the corpus path can revert to the pre-erasure inode — the very scenario the docstring claims to defend against. The same gap exists in forgelm/data_audit/_orchestrator.py::_atomic_write_json.

🛡️ Proposed fix: fsync parent directory after replace
             out.flush()
             os.fsync(out.fileno())
         os.replace(tmp_path, corpus_path)
+        # Make the rename itself durable: without this, a power loss
+        # between the namespace swap and the directory-entry flush can
+        # leave the corpus path pointing back at the pre-erasure inode.
+        try:
+            dir_fd = os.open(parent, os.O_DIRECTORY)
+        except (OSError, AttributeError):
+            # Windows / no O_DIRECTORY support — skip; os.replace is
+            # already crash-safe on NTFS.
+            pass
+        else:
+            try:
+                os.fsync(dir_fd)
+            finally:
+                os.close(dir_fd)
     except OSError:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@forgelm/cli/subcommands/_purge.py` around lines 235 - 279, The atomic rewrite
in _atomic_rewrite_dropping_lines currently flushes and fsyncs the temp file but
omits fsyncing the parent directory after os.replace, so add a directory fsync
immediately after os.replace(tmp_path, corpus_path): open the parent directory
(using os.open with O_DIRECTORY), call os.fsync on that directory FD, then close
it (ensuring this is in a try/finally or similar to always close); keep the
existing temp-file cleanup/exception behavior, and apply the same
directory-fsync change in
forgelm/data_audit/_orchestrator.py::_atomic_write_json.

Code (1):

- _purge.py + data_audit/_orchestrator.py F-21-04: parent-dir fsync
  added after os.replace.  POSIX rename atomicity is necessary but
  not sufficient on non-journaled filesystems (FAT, certain tmpfs);
  fsyncing the parent directory after the rename ensures the
  directory entry's metadata is on disk so a power loss between the
  rename and the metadata flush cannot lose the entry.  ``O_DIRECTORY``
  is unsupported on Windows; the open is wrapped in
  ``try/except (AttributeError, OSError)`` and the fd is always closed
  in a ``finally``.  Both atomic-rewrite sites get the same
  discipline so the two helpers don't fork their durability story.

Docs (1):

- docs/analysis/code_reviews/wave2b-final-merge-review-prompt.md:
  L21 ``Diff scope vs main`` → ``vs development`` (last residual
  ``main`` reference; PR base + goal text already corrected in
  prior round).

Tests (1):

- tests/test_check_field_descriptions.py: switched from
  ``sys.path`` mutation to ``importlib.util.spec_from_file_location``
  + ``module_from_spec`` + sys.modules registration + ``exec_module``.
  Avoids leaking ``tools/`` onto the import path for the rest of the
  pytest invocation.  ``sys.modules`` registration is load-bearing
  because the scanner uses ``@dataclass(frozen=True)`` which resolves
  its parent module via ``sys.modules[cls.__module__]`` at
  class-creation time — the comment explains the failure mode so a
  future refactor doesn't break the registration.

Skipped (1, documented):

- ``test_check_field_descriptions.py`` exit-code 0/1/2/3/4 contract
  tests — the scanner is a CI-only tool, not a forgelm subcommand.
  The 0/1/2/3/4 public contract per ``docs/standards/error-handling.md``
  applies to the ``forgelm`` binary's subcommands; ``tools/check_field_descriptions.py``
  returns 0 (clean) or 1 (strict + missing) only.  Asserting non-existent
  contracts (3/4) would pin behaviour the code never promised.

VALIDATION

- ruff format clean
- ruff check clean
- pytest 1298 passed / 14 skipped
- forgelm --dry-run config_template.yaml green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

sonarqubecloud Bot commented May 5, 2026

Copy link
Copy Markdown

@cemililik
cemililik merged commit b05edb5 into development May 5, 2026
3 of 4 checks passed
cemililik added a commit that referenced this pull request May 5, 2026
PR #30 merged to development at commit b05edb5 (2026-05-05).
Closure plan updated to reflect post-merge state:

- Status legend: target branch corrected from "main" → "development"
  for PRs #21, #28, #30 (the project merges into development first;
  development → main happens at release time).
- Wave 2b kapanış özeti: merge commit SHA recorded; suite count
  delta noted (1160 → 1298, +138 net tests across the wave).
- Version log v2.7: rewritten from "merge öncesi" → "merge sonrası"
  with merge commit SHA + Wave 3 immediate-batch readiness note
  (Faz 22 + 24 + 38 dependency-free).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cemililik
cemililik deleted the closure/wave2b-integration branch May 10, 2026 11:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant