Skip to content

feat(foundation): give the workspace root and a study's directory distinct types - #49

Merged
nilsonfh merged 2 commits into
mainfrom
fix/name-the-roots
Sep 15, 2026
Merged

nilsonfh merged 2 commits into
mainfrom
fix/name-the-roots

Conversation

@nilsonfh

@nilsonfh nilsonfh commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 1 of bd 6ns.1: the workspace root and a study's directory get distinct types, so mypy rejects passing one where the other is required.

WorkspaceRoot and ProjectDir are NewTypes over Path in gridalyn/foundation/platform/roots.py. They are identity at run time, so no behaviour changes; the only effect is what mypy accepts.

Why. bd 7rt passed a study's directory where materialize_flexibility_operation_artifacts needed the workspace root. A flagship stage died after writing 21 MB under projects/<study>/projects/<study>/. The fix that followed passed script.base_dir, which equals the workspace root only for the 2 of 9 studies with pathBase: repo (measured across all 9). This PR makes that call a type error and moves the flagship to script.workspace_root.

What changes

  • Producers:

    • find_workspace_root / find_repo_root return WorkspaceRoot.
    • StudyProject.root and ProjectScript.root are ProjectDir.
    • New ProjectScript.workspace_root, discovered from root, so it does not depend on pathBase.
    • base_dir stays Path, documented as the stage cwd.
  • Consumers that take the workspace root:

    • ArtifactLayout, GridalynWorkspace, workspace_from_*, layout_from_environment;
    • operations/artifacts.py;
    • build_project_catalog, build_dashboard_catalog;
    • build_digital_twin_reports, run_digital_twin_build, generate_locational_clearing.

    prepare_project_workspace takes a ProjectDir.

  • Typed re-export. gridalyn.foundation resolved these names through an untyped lazy __getattr__, so to mypy they were Any, and 27 of the 34 imports were never checked. Both foundation __init__s now re-export them under TYPE_CHECKING. Run time stays lazy, and the lazy maps also gain the two types, so from gridalyn.foundation import WorkspaceRoot works at run time too.

  • The 23 call sites that surfaced:

    • 14 module constants that are the repo root by construction;
    • 4 Path(".") defaults;
    • 3 parameters whose docstrings already named the workspace root;
    • the twin CLI's validated --root;
    • in the twin, one line, agreed with the twin's owner: twin/adapters/network.py's ArtifactLayout(root) becomes ArtifactLayout(WorkspaceRoot(root)), with a comment. The public export(root) signatures stay Path until the twin-owned phase.
  • Tests: tests/test_workspace_roots.py pins the values under both pathBase conventions. It also runs mypy on two snippets: root=script.root must be rejected, and root=script.workspace_root must be accepted. That protects the contract if someone later makes the type a plain alias.

  • Docs: docstrings, plus prose in docs/components/{foundation,projects}.md. No fenced block was added; ledger lines were re-derived through the tool. The four docs/components/projects.md entries were already 7 lines stale on main (ledger 52/100/106/112, actual 59/107/113/119); with this PR's one added line they are re-derived to 60/108/114/120. foundation.md#2 moves 130 → 140 from the new paragraph.

Not in this phase

  • The twin's own base_dir (its canonical base-artifact directory) is a different concept from StudyProject.base_dir. It gets its own NewType in a twin-owned phase.
  • The other root-taking functions (97 in gridalyn/) follow layer by layer.

Verification

After rebasing onto 2fbb8315 (#48)

#48 merged while this was in review, so this branch was rebased. It is now 70c98fb1 (phase 1) + 6ce1b5af (baselines and review).

  • Overlap with feat(projects): let a declared extension contribute a semantic capability #48: only gridalyn/projects/scripting.py, and git merged it cleanly. feat(projects): let a declared extension contribute a semantic capability #48 adds resolve_extensions at the end of ProjectScript, away from root, workspace_root and base_dir. git merge-tree reported no conflicts.
  • Semantic check: feat(projects): let a declared extension contribute a semantic capability #48 adds no code that uses the workspace names or roots this PR types (ArtifactLayout, layout_from_environment, workspace_from_*, find_workspace_root, prepare_project_workspace, the catalog and operations helpers, .base_dir, script.root), and changes no ledger entry.
  • Rebased tree: pinned black/isort/flake8 clean; the three ratchets as the pre-push hook runs them read gridalyn 116/116, projects 846/846, twin 12/12; doc instruction ledger OK; every touched module imports at run time.
  • Full suite: 2233 passed, 26 skipped, 1 xfailed, 0 failed.
  • CI on the rebased head (6ce1b5af): success on attempt 1. test, lint, docs and Governed project contracts all pass. Its mypy counts are gridalyn 116/116, twin 12/12 and projects 758 (baseline 846; the gap between the local and CI environments predates this PR).

Before the rebase (on 872dcd47)

  • Simulated before implementing, in throwaway worktrees with mypy A/B compared per (file, message).
    • A positive control confirmed mypy was reading the patched tree: the flagship's root=script.base_dir showed up as a new error.
    • The final simulation had 0 new errors in all three targets.
    • 4 pre-existing errors disappear (Unsupported left operand type for / ("str") in workspace.py).
  • Runtime import of every touched module, each in its own subprocess: 0 failures.
  • Full suite: 2223 passed, 26 skipped, 1 xfailed, 0 failed.
  • mypy, same method as the A measurement: 0 new errors in all three targets (gridalyn 120 → 116, projects 850 → 846, twin 12 → 12). (CI's authoritative counts on main are gridalyn 120/120, twin 12/12, projects 762/850.)
  • Pinned black/isort/flake8: clean. Doc instruction ledger: OK.
  • Review: requested from the twin's owner, who already agreed the one twin line.

🤖 Generated with Claude Code

nilsonfh and others added 2 commits September 15, 2026 18:47
…tinct types

Phase 1 of bd 6ns.1 (name the roots so the wrong one cannot be picked
silently). WorkspaceRoot and ProjectDir are NewTypes over Path in
gridalyn/foundation/platform/roots.py: identity at run time, distinct to mypy.

Why: bd 7rt passed a study's directory where the workspace root was required.
A flagship stage died after writing 21 MB under a doubled
projects/<study>/projects/<study>/. The fix that followed passed
script.base_dir instead, and base_dir equals the workspace root only for the
2 of 9 studies with pathBase: repo.

- Producers: find_workspace_root and find_repo_root return WorkspaceRoot.
  StudyProject.root and ProjectScript.root are ProjectDir. The new
  ProjectScript.workspace_root is discovered from root, so it does not depend
  on pathBase. base_dir stays a Path, documented as the stage cwd.
- Consumers: ArtifactLayout/GridalynWorkspace and the workspace_from_* and
  layout_from_environment helpers take a WorkspaceRoot, and so do
  operations/artifacts.py (materialize_flexibility_operation_artifacts and its
  helpers), build_project_catalog, build_dashboard_catalog,
  build_digital_twin_reports, run_digital_twin_build and
  generate_locational_clearing. prepare_project_workspace takes a ProjectDir.
- The flagship's analyze_locational_contracts stage passes
  script.workspace_root, off the pathBase coincidence.
- gridalyn.foundation and gridalyn.foundation.platform re-export the workspace
  names and both types under TYPE_CHECKING. Their untyped lazy __getattr__ made
  those names Any to mypy, so 27 of 34 imports were never checked. Run time
  stays lazy, and the lazy maps gain ProjectDir and WorkspaceRoot so the
  runtime import matches.
- Call sites the typed re-export surfaced (23, all "Path where WorkspaceRoot is
  expected"): 14 module constants that are the repo root by construction
  (parents[4]), 4 Path(".") defaults, 3 parameters whose docstrings already
  named the workspace root, and the twin CLI's _require_workspace_root, which
  now returns a WorkspaceRoot. In the twin, only twin/adapters/network.py
  changes: its one ArtifactLayout(root) wraps WorkspaceRoot(root), with a
  comment, and the exporters' Path signatures wait for the twin-owned phase
  (agreed with the twin's owner).
- tests/test_workspace_roots.py pins the values per pathBase, and runs mypy on
  two snippets: root=script.root where a WorkspaceRoot is required is rejected,
  and root=script.workspace_root is accepted.
- Docs: docstrings on StudyProject, ProjectScript.root/workspace_root/base_dir
  and ArtifactLayout. Prose in docs/components/foundation.md and projects.md,
  with no fenced block added; ledger lines re-derived through the tool.

Not in this phase: the twin's own base_dir (canonical base directory) gets a
separate NewType in a twin-owned phase; the remaining root-taking functions
come layer by layer.

Verified: simulated first in throwaway worktrees with mypy A/B per (file,
message); a positive control (the flagship's root=script.base_dir surfacing)
confirmed mypy read the patched tree. On this tree: 0 new mypy errors in
gridalyn, projects and gridalyn/twin, with 4 pre-existing errors gone; full
suite 2223 passed / 0 failed; every touched module imports at run time;
pinned black/isort/flake8 clean; doc instruction ledger OK.

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

The typed foundation re-export removed 4 pre-existing `Unsupported left operand
type for / ("str")` errors in workspace.py.

- .mypy-baseline 120 -> 116: CI's authoritative count on 4ceee0e, and the
  count the local environment measures too.
- .mypy-baseline-projects 850 -> 846: the local count the pre-push hook sees.
  CI reports 758 there. The gap between the two environments predates this
  change, and a baseline below the local count would block every push.
- docs/contributing/verification.md now cites 116 and 846. It said 120, and
  851, which was already stale.

Review:
- ProjectScript.workspace_root is a cached_property. Discovery can spawn
  `git rev-parse`, so a loop over the property paid a subprocess on every read.
- The mypy test passes --follow-imports=silent, so each snippet keeps the
  imported types and reports only its own errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nilsonfh
nilsonfh merged commit b3342c4 into main Sep 15, 2026
7 checks passed
@nilsonfh
nilsonfh deleted the fix/name-the-roots branch September 17, 2026 14:02
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