Compare commits

...

36 Commits

Author SHA1 Message Date
Julian Noble bfcbf705be add goal G-029: build packaged test::<modulename> modpods from src/tests 3 weeks ago
Julian Noble 64fa9a63a7 add goal G-028: name the process locking a file on build deploy failures 3 weeks ago
Julian Noble 92f6c30442 add goal G-027: pull-based infrastructure updates for derived projects 3 weeks ago
Julian Noble 6d01b6e93b shellfilter 0.2.3: survive infra errors in run; runtests tm order + Thread path 3 weeks ago
Julian Noble d88e1c1759 punkboot::utils 0.1.1: move vendorupdate dirty-checkout check into module 3 weeks ago
Julian Noble 69d9b8f23a vendorupdate: warn when pulling from dirty source checkouts; goal G-026 3 weeks ago
Julian Noble 87ccbc992e add goals G-023..G-025: versioned binaries, mapvfs toml, exe self-report 3 weeks ago
Julian Noble 330ae00de3 G-022 fossil rename goal + move/rename characterization tests; fix shellrun 3 weeks ago
Julian Noble 5e3e3efa99 add goals G-018..G-021: build spectrum + screen capture/input automation 3 weeks ago
Julian Noble 5c5174f730 add goals G-015/G-016/G-017: piped script calls + projects.work discovery 3 weeks ago
Julian Noble 3a32b4037f G-014: require tomlish module for toml parsing, punk::args docs precondition 3 weeks ago
Julian Noble 7cb55275c8 remove inapplicable bootsupport stuff for this template - proper fix pending goal G-012 fixes 3 weeks ago
Julian Noble 4b91fd6f4c add goals G-013 (raw-mode repl default) and G-014 (punk::config toml) 3 weeks ago
Julian Noble c18b2d9320 project version 0.2.3: shellrun runx -debug/--timeout fix + -tcl removal from sh_* 3 weeks ago
Julian Noble 24a7d50f3b shellrun 0.1.3: migrate runx + sh_* to punk::args, remove get_run_opts, expand PUNKARGS docs 3 weeks ago
Julian Noble 28106ac4b2 punk::console 0.7.1: idle-reader hostage guard - fail fast instead of timeout + phantom input 3 weeks ago
Julian Noble c8072d7945 Extract punkboot::utils module + tests, bump project to 0.2.1 3 weeks ago
Julian Noble 78810ba0af make.tcl: restore 'shell' command guard deleted by projectversion edit 3 weeks ago
Julian Noble 0ced0e1f2f update bootsupport,vfs 3 weeks ago
Julian Noble 717432676f project versioning: change-driven bump policy + make.tcl projectversion check 3 weeks ago
Julian Noble 49985aaba4 add goal G-012: template system - inert VCS-config payloads and explicit layout refresh 3 weeks ago
Julian Noble fc1c474cf1 git-track layout README placeholders and vendored critcl TODO.txt (force-add past accidental ignores) 3 weeks ago
Julian Noble 633616bad2 dual-VCS contract: record nested-.gitignore and case-sensitivity asymmetries 3 weeks ago
Julian Noble fc537c9456 dual-VCS coexistence contract: untrack fossil-generated manifests, add .fossil-settings/AGENTS.md 3 weeks ago
Julian Noble 03678eb72a punkproject.toml: set project version 0.2.0 3 weeks ago
Julian Noble 6850059964 sync .fossil-settings/ignore-glob with .gitignore intent 3 weeks ago
Julian Noble 7448dcad8a add bin/AGENTS.md: launch package modes (src mode) and verification-shell contracts 3 weeks ago
Julian Noble c19c1167c8 punk::console 0.7.1: compound emit-then-query operations flush before querying 3 weeks ago
Julian Noble d68d4d4f26 punk::console 0.7.1: ensure_object_integration hardening - graceful no-op + catch-up ownership registration 3 weeks ago
Julian Noble 552a311d6e punk::repl 0.2.2: waiting-chunks path avoids drained-channel read on tcl 8.6 consoles 3 weeks ago
Julian Noble 528d1eca72 punk::console 0.7.1: fix tcl 8.6 windows console misdetection - one root cause, three sites 3 weeks ago
Julian Noble 0d4d5aa036 G-007 achieved: interactive acceptance verified on tcl 8.6 and tcl 9 3 weeks ago
Julian Noble 4501c37723 G-011: review-driven refinements - testable fact key, choke-point surface, backend default, stty-state disposition 3 weeks ago
Julian Noble 4892acb501 add goal G-011: optional per-console err channel with defined stderr semantics 3 weeks ago
Julian Noble c3f9180cf0 update bootsupport,vfs 3 weeks ago
Julian Noble 6314312aa4 punk::console 0.7.0: G-007 slice 2 - choke-point brokering of terminal queries to the console-owning thread 3 weeks ago
  1. 46
      .fossil-settings/AGENTS.md
  2. 72
      .fossil-settings/ignore-glob
  3. 9
      .gitignore
  4. 35
      AGENTS.md
  5. 41
      CHANGELOG.md
  6. 115
      GOALS.md
  7. 32
      bin/AGENTS.md
  8. 43
      goals/G-007-console-location-transparency.md
  9. 107
      goals/G-011-console-stderr-semantics.md
  10. 80
      goals/G-012-template-payload-safety.md
  11. 71
      goals/G-013-raw-mode-default.md
  12. 61
      goals/G-014-punk-config-toml.md
  13. 85
      goals/G-015-script-subcommand-piped-stdin.md
  14. 58
      goals/G-016-projects-work-git-discovery.md
  15. 56
      goals/G-018-zig-plain-tclsh-kits.md
  16. 67
      goals/G-019-dependency-scan-module-trimming.md
  17. 86
      goals/G-020-screencap-input-module.md
  18. 55
      goals/G-021-agent-visual-verification.md
  19. 75
      goals/G-022-fossil-rename-punkshell.md
  20. 67
      goals/G-023-version-named-binaries.md
  21. 65
      goals/G-024-mapvfs-toml.md
  22. 82
      goals/G-025-exe-selfreport.md
  23. 76
      goals/G-026-vendor-provenance-policy.md
  24. 82
      goals/G-027-derived-project-pull-updates.md
  25. 67
      goals/G-028-file-locker-identification.md
  26. 77
      goals/G-029-testmodules-from-srctests.md
  27. 8
      manifest
  28. 2
      manifest.tags
  29. 1
      manifest.uuid
  30. 1
      punkproject.toml
  31. 3
      src/AGENTS.md
  32. 13
      src/bootsupport/AGENTS.md
  33. 1
      src/bootsupport/modules/include_modules.config
  34. 159
      src/bootsupport/modules/punk/console-0.7.1.tm
  35. 51
      src/bootsupport/modules/punk/repl-0.2.2.tm
  36. 214
      src/bootsupport/modules/punkboot/utils-0.1.1.tm
  37. 42
      src/bootsupport/modules/shellfilter-0.2.3.tm
  38. 421
      src/bootsupport/modules/shellrun-0.1.4.tm
  39. 106
      src/make.tcl
  40. 1
      src/modules/punk/AGENTS.md
  41. 153
      src/modules/punk/console-999999.0a1.0.tm
  42. 8
      src/modules/punk/console-buildversion.txt
  43. 49
      src/modules/punk/repl-999999.0a1.0.tm
  44. 3
      src/modules/punk/repl-buildversion.txt
  45. 214
      src/modules/punkboot/utils-999999.0a1.0.tm
  46. 5
      src/modules/punkboot/utils-buildversion.txt
  47. 40
      src/modules/shellfilter-999999.0a1.0.tm
  48. 5
      src/modules/shellfilter-buildversion.txt
  49. 419
      src/modules/shellrun-999999.0a1.0.tm
  50. 13
      src/modules/shellrun-buildversion.txt
  51. 106
      src/project_layouts/custom/_project/punk.basic/src/make.tcl
  52. 1
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/include_modules.config
  53. 159
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/console-0.7.1.tm
  54. 51
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/repl-0.2.2.tm
  55. 214
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punkboot/utils-0.1.1.tm
  56. 42
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/shellfilter-0.2.3.tm
  57. 421
      src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/shellrun-0.1.4.tm
  58. 106
      src/project_layouts/custom/_project/punk.project-0.1/src/make.tcl
  59. 1
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/include_modules.config
  60. 159
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/console-0.7.1.tm
  61. 51
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/repl-0.2.2.tm
  62. 214
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punkboot/utils-0.1.1.tm
  63. 42
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/shellfilter-0.2.3.tm
  64. 421
      src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/shellrun-0.1.4.tm
  65. 106
      src/project_layouts/custom/_project/punk.shell-0.1/src/make.tcl
  66. 3
      src/project_layouts/vendor/punk/project-0.1/bin/README.md
  67. 26
      src/project_layouts/vendor/punk/project-0.1/bin/runtime/README.md
  68. 6
      src/project_layouts/vendor/punk/project-0.1/lib/README.md
  69. 9
      src/project_layouts/vendor/punk/project-0.1/modules/README.md
  70. 2
      src/tests/modules/AGENTS.md
  71. 97
      src/tests/modules/punk/console/testsuites/console/objectintegration.test
  72. 125
      src/tests/modules/punk/console/testsuites/console/ownerrouting.test
  73. 66
      src/tests/modules/punk/console/testsuites/console/probes.test
  74. 251
      src/tests/modules/punk/mix/testsuites/repo/fossilmove.test
  75. 198
      src/tests/modules/punkboot/utils/testsuites/utils/utils.test
  76. 98
      src/tests/modules/punkboot/utils/testsuites/utils/vcsdirty.test
  77. 22
      src/tests/runtests.tcl
  78. 134
      src/vfs/_vfscommon.vfs/modules/punk/console-0.7.1.tm
  79. 51
      src/vfs/_vfscommon.vfs/modules/punk/repl-0.2.2.tm
  80. 13
      src/vfs/critcl-3.3.1.vfs/TODO.txt
  81. 13
      src/vfs/critclxxx.vfs/TODO.txt

46
.fossil-settings/AGENTS.md

@ -0,0 +1,46 @@
# .fossil-settings/
## Purpose
Versioned fossil settings (one setting value or glob list per file) for this dual-VCS tree: git and fossil both track the same project. This file is also the canonical home of the git+fossil coexistence contract, including the rules for keeping `.gitignore` and `ignore-glob` in sync.
## Ownership
- `.gitignore` is the canonical statement of ignore intent; `ignore-glob` is hand-derived from it per the rules below - never the other direction.
- `.fossil-settings/` and `.fossil-custom/` are git-tracked (versioned fossil config rides in git even at sites where fossil is unused).
## Local Contracts
### Files each system must not track
- **Fossil-generated checkout artifacts**: the `manifest` setting (value `rut`) makes fossil regenerate `manifest`, `manifest.uuid` and `manifest.tags` at the checkout root on every checkin/checkout. Fossil auto-manages them (they never appear in extras and cannot be added). Git must ignore them - root-anchored `/manifest`, `/manifest.uuid`, `/manifest.tags` in `.gitignore` (anchored because nested manifest-named files elsewhere are real content) - and must never track them.
- **Fossil control files**: `.fslckout` / `_FOSSIL_` (checkout db), `*.fossil` (repository db) - git-ignored; fossil auto-ignores its own.
- **Git control tree**: `.git` stays in `ignore-glob`. Fossil skips dot-prefixed names by default, but an `addremove --dotfiles` scan would otherwise descend into it.
- Git meta files that are project content (`.gitignore`, `.gitkeep` placeholders) ARE fossil-tracked for mirror completeness - capture them with `--dotfiles` on addremove/add scans.
### ignore-glob derivation rules (verified against fossil 2.28)
- **No negation exists in fossil globs.** Git's `/bin/*` + `!exception` pattern becomes: ignore `bin/*` wholesale and explicitly `fossil add` the tracked exceptions (ignore-glob never affects already-managed files, so they stay tracked). The exception set is whatever git tracks under an ignored tree - at the time of writing: `bin/AGENTS.md`, `bin/*.cmd`, `bin/*.kit`, `bin/*.tcl`, `bin/*.sh`, `bin/*.bash`, plus force-tracked one-offs (`bin/libssp-0.dll`, `src/vfs/punk9magicsplat.vfs/lib/nmake/x86_64-w64-mingw32-nmakehlp.exe`). Derive the current set with the verification comparison below rather than trusting this list.
- A bare directory name prunes that whole tree; `*` crosses `/`; git patterns intended to match at any depth need an additional `*/` variant; `#` comment lines are honoured.
- Do not add the fossil-generated names (`manifest`, `manifest.uuid`, `manifest.tags`) to `ignore-glob` - fossil handles them itself.
- **Nested `.gitignore` files are git-only**: ignore files inside subtrees (e.g. the project-layout templates under `src/project_layouts/`) are honoured by git as nested ignores of the outer repo but invisible to fossil - template content they match is untracked in git yet managed by fossil. Treat such divergence as a signal to review the git side (usually `git add -f` of the affected template files).
- **Case sensitivity differs**: git ignore matching is case-insensitive on Windows (`core.ignorecase`), fossil glob matching is case-sensitive - e.g. a `todo.txt` pattern hits vendored `TODO.txt` in git but not fossil.
### Safe sync procedure (when asked to "sync the ignores")
1. Edit `.gitignore` first (canonical), then hand-translate to `ignore-glob` using the rules above. Never translate a negation literally.
2. Verify from the checkout root:
- `fossil extras | git check-ignore --stdin` must output **nothing** (fossil is not under-ignoring relative to git).
- Git-tracked files invisible to fossil must reduce to the documented exception set plus the fossil-generated manifests: compare `git ls-files | sort` against `(fossil ls; fossil extras) | sort` (beware false diffs from case and non-ASCII path encoding).
3. A fossil `addremove` after a glob change should use `--dotfiles` and be reviewed before commit; pending adds captured under older globs are stale - `fossil revert` cancels uncommitted adds without touching file content when nothing is committed on top of them.
## Work Guidance
## Verification
- `fossil extras | git check-ignore --stdin` → empty
- `git ls-files | sort` vs `(fossil ls; fossil extras) | sort` → differences limited to the documented exception set + fossil-generated manifests
## Child DOX Index
(none)

72
.fossil-settings/ignore-glob

@ -1,38 +1,39 @@
#Derived from .gitignore (the canonical statement of ignore intent) - keep in sync manually.
#See .fossil-settings/AGENTS.md for the git+fossil coexistence contract: the glob semantic
#translation rules (fossil has no negation - bin/* is wholesale-ignored with the tracked
#exceptions handled by explicit 'fossil add'), and the verification procedure for syncing.
.git
CVS
bin/*.exe
bin/*.zip
bin/*.bak
bin/*.ps1
bin/tools
bin/runtime
bin/*test*
#bin is primarily build output - tracked exceptions by explicit add (see header)
bin/*
#directories for compiled/built Tcl packages - purely output
lib
#The directory for compiled/built Tcl modules
lib_tcl8
lib_tcl9
modules
#The buildsuites output folder
builds
modules_tcl8
modules_tcl9
vendorbuilds
localdoc
#Temporary files e.g from tests
tmp
.vscode
*/.vscode
.claude
*/.claude
.omo
#subfolder for temporary reference files - not source controlled
#we may use git clone to get a copy of the reference files - but we don't want to track them in the repo
TEMP_REFERENCE
#Temporary files e.g from tests
tmp
*/.omo
logs
_aside
#working directories
*/_aside
_build
src/_build
src/docgen
*/_build
scratch*
*/scratch*
#Built documentation
html
@ -41,22 +42,41 @@ md
doc
test*
src/testdata
src/scriptapps/test_*
#temp ignore - not ripe
src/buildsuites
#review
src/docgen
#local developer notes
localdoc
#Built tclkits (if any)
#punk*.exe
#tcl*.exe
*.exe
#subfolder for temporary reference files - not source controlled
TEMP_REFERENCE
*/TEMP_REFERENCE
#fossil database files (fossil auto-ignores its own control files; an in-tree repo file would otherwise list as an extra)
*.fossil
#miscellaneous editor files etc
*.swp
*.log
.punkcheck
*.punkcheck
todo.txt
*/todo.txt
zig-cache
*/zig-cache
zig-out
*/zig-out
release
debug
build

9
.gitignore vendored

@ -1,6 +1,7 @@
#while bin is primarily an output - some items here are for bootstrapping - so while they aren't the source - we install and source-control them.
/bin/*
!/bin/AGENTS.md
!/bin/*.cmd
!/bin/*.kit
!/bin/*.tcl
@ -63,6 +64,14 @@ _FOSSIL_
.fslckout
*.fossil
#fossil-generated checkout artifacts - regenerated on every fossil checkin/checkout per the
#versioned 'manifest' setting (value 'rut'); fossil auto-manages them and they must not be
#git-tracked. Anchored: nested manifest-named files elsewhere are real content.
#See .fossil-settings/AGENTS.md for the git+fossil coexistence contract.
/manifest
/manifest.uuid
/manifest.tags
#miscellaneous editor files etc
*.swp
*.log

35
AGENTS.md

@ -113,6 +113,38 @@ When the user requests a durable behavior change, record it here or in the relev
- `secondary-models` is intentionally omitted (not reliably introspectable). May be added later if a harness surfaces subagent model info.
- The `Assisted-by` trailer describes tooling, not authorship; it does not replace or conflict with the `Co-Authored-By` ban above.
## Project Versioning
The punkshell project version is tracked in `punkproject.toml` (`[project] version`) at the repo root and is independent of individual module versions (those live in `src/modules/*-buildversion.txt` per `src/modules/AGENTS.md` "Versioning And Releases"). The project version describes the user-visible punkshell product, not any single module.
### Bump policy (change-driven)
An agent must bump the `punkproject.toml` version as part of its DOX closeout pass whenever its change ships user-visible shell behaviour. This is change-driven, not release-driven — the version stays honest between releases.
- **Patch** — bug fixes, internal refactors, doc-only updates that ship in a build without changing user-facing shell behaviour.
- **Minor** — new shell commands, new launchers, new default modules visible at the REPL, backward-compatible behaviour additions.
- **Major** — removed commands, changed default behaviour, changed launch invocation, breaking changes to the shell's user-facing contract.
Changes confined to tests, build tooling internals, or non-shipped surfaces do not require a bump. When in doubt, bump patch.
The `make.tcl` command interface is part of the product surface: new subcommands, changed subcommand behaviour, or removed subcommands warrant at least a patch bump. Internal refactors of an existing subcommand's implementation that leave its interface and output unchanged stay exempt as build tooling internals.
### Changelog
Every version bump must add a corresponding entry to `CHANGELOG.md` at the repo root. The latest `## [X.Y.Z]` header in that file must match the `version` field in `punkproject.toml`. Entries are newest-first, one bullet per notable change.
### Enforcement
`tclsh src/make.tcl projectversion` is a read-only check (never aborts, only warns) that:
1. Verifies the `CHANGELOG.md` latest version header matches `punkproject.toml`.
2. Checks whether `src/` has git commits since the last commit touching `punkproject.toml`; if so, warns that a project-version bump may be overdue.
The check is advisory — it catches forgotten bumps, not hard errors. Agents should run it during closeout for any `src/` change that ships behaviour, alongside existing `make.tcl` verification.
### Relationship to module versions
The project version is fully independent of module versions. A module bump (even a major one) does not force a project bump unless the module's change is itself user-visible at the shell level. Conversely, a project bump does not require bumping any module.
## Child DOX Index
- `src/` — Source tree root; editable source code, build scripts, tests, VFS payloads, vendor deps, docs (see src/AGENTS.md)
@ -137,6 +169,8 @@ When the user requests a durable behavior change, record it here or in the relev
- `src/runtime/` — Build runtimes and VFS config (see src/runtime/AGENTS.md)
- `src/doc/` — Generated documentation (see src/doc/AGENTS.md)
- `src/testansi/` — Sample ANSI art files (do not modify)
- `bin/` — Built punk shell executables, launch package modes (incl. `src` mode for working-tree verification), plain runtime kits (see bin/AGENTS.md)
- `.fossil-settings/` — Versioned fossil settings and the git+fossil dual-VCS coexistence contract: ignore-sync rules, files neither system may track (see .fossil-settings/AGENTS.md)
- `GOALS.md` — Top-level technical goal index; required read for non-trivial work (no child AGENTS.md; the file documents its own format and the agent goal-authoring workflow)
- `GOALS-archive.md` — One-line records of achieved goals moved out of the active index (historical context only)
- `goals/` — Optional detail prose for goals needing more than a one-line summary (see goals/AGENTS.md)
@ -149,6 +183,7 @@ When the user requests a durable behavior change, record it here or in the relev
- `lib/` (root) — Build output target for `tclsh src/make.tcl libs`
- `modules_tcl8/`, `modules_tcl9/` — Tcl version-specific build output targets
- `lib_tcl8/`, `lib_tcl9/` — Tcl version-specific build output targets
- `src/project_layouts/` — Project layout templates; partly updated by `make.tcl bootsupport` but full synchronisation requires manual work or future `make.tcl` improvements. Agents must not directly edit files under `src/project_layouts/` unless the user explicitly names a path there or specifically requests layout changes. When a `src/make.tcl` change affects layout make.tcl files, note it in the completion report for the user to handle — do not attempt manual synchronisation.
## Repo-wide Notes

41
CHANGELOG.md

@ -0,0 +1,41 @@
# Changelog
All notable changes to the punkshell project version are documented here.
The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.toml`.
Entries are newest-first; one bullet per notable change. See the root `AGENTS.md`
"Project Versioning" section for the bump policy.
## [0.2.6] - 2026-07-06
- shellfilter 0.2.3: `shellfilter::run` now guarantees tee-stack removal (stdout/stderr restoration) via try/finally even when its body errors, and the decorative stack-status table rendering in `log::critical` blocks is catch-guarded. Previously an error there (observed: a stale same-version textblock snapshot calling the new punk::ansi `sgr_merge_singles` with old option syntax) aborted the run between channel diversion and restoration, leaving the process's stdout/stderr broken and all subsequent output silently lost.
- runtests.tcl: testinterp tm path order fixed so the bootsupport snapshot wins same-version module ties over vendormodules (verified rule on Tcl 9.0.3, standard and punk::libunknown scanners: `tcl::tm::add` prepends, head of `tcl::tm::list` wins ties, so the last `test_tmlist` element wins); testinterp auto_path gains the parent of `[info library]` so binary packages such as Thread resolve in projects without a root `lib_tcl<N>/<arch>` payload.
## [0.2.5] - 2026-07-06
- `make.tcl vendorupdate` now checks each vendor source project's fossil/git checkout state and warns (non-fatal, once per VCS root) when pulling from a dirty tree — vendored artifacts built from uncommitted source have no committed provenance. Enforcement policy (abort/override, bootsupport coverage) tracked by goal G-026.
- punkboot::utils 0.1.1: the dirty-checkout check lives in the module as `vcs_dirty_warnings` (make.tcl calls it via a guarded lazy require and degrades to skipped if the bootsupport snapshot lacks it); bootsupport snapshots refreshed (punkboot::utils 0.1.1, shellrun 0.1.4, punk::console 0.7.1 content catch-up).
## [0.2.4] - 2026-07-06
- shellrun 0.1.4: added missing `package require punk::args` (the 0.1.3 migration left `runx`/`run`/`runout`/`runerr` calling `punk::args::parse` without requiring it, breaking shellrun in bare interps — including the `src/tests/runtests.tcl` testinterp, which made every source-tree test run fail).
## [0.2.3] - 2026-07-06
- shellrun: removed `-tcl` from `sh_run`, `sh_runout`, `sh_runerr`, `sh_runx` (was silently swallowed with no useful effect because `sh -c` is always an external shell; now rejected with a usage message).
- shellrun: migrated `runx` from the legacy `get_run_opts` parser to `punk::args::parse` with a full PUNKARGS spec; `runx` now honours `-debug` and `--timeout=` (previously accepted but silently ignored).
- shellrun: expanded PUNKARGS documentation for `run`, `runconsole`, `runout`, `runerr`, and `runx` with `@cmd` metadata and `-help` text on all opts/values.
- shellrun: migrated `sh_run`, `sh_runout`, `sh_runerr`, `sh_runx` from `get_run_opts` to `punk::args::parse` with dedicated PUNKARGS specs.
- shellrun: removed the internal `get_run_opts` proc; all callers now use `punk::args`.
## [0.2.2] - 2026-07-06
- punk::console: terminal queries fired while an 8.6-based shell waits at an idle line-mode prompt now fail fast with a discriminable errorcode (`PUNK CONSOLE QUERY HOSTAGE_COOKED_READ`) and emit nothing, instead of timing out (~500ms) and later corrupting input with the swallowed response (idle-reader hostage guard; raw mode and mid-command queries unaffected). Owner-routed queries preserve the owner-side errorcode.
## [0.2.1] - 2026-07-06
- `make.tcl projectversion`: new advisory subcommand verifying `CHANGELOG.md` matches `punkproject.toml` and warning if `src/` has commits since the last project-version bump.
- Clarified project versioning policy: the `make.tcl` command interface is part of the product surface.
## [0.2.0] - 2026-07-05
- Initial tracking of the punkshell project version in `punkproject.toml`.

115
GOALS.md

@ -86,7 +86,7 @@ Detail: goals/G-006-prebuilt-artifact-download.md
Goal: pre-built binary artifacts can be downloaded from a separate related binary-artifacts repository or user-configured sources, gated by explicit user consent/configuration by default.
Acceptance: a download mechanism fetches binary artifacts (the same set the zig build produces) from a configured source on demand; by default the download is gated behind explicit user consent (a config flag or interactive prompt) and does not occur silently; a user-configured source URL overrides the default binary-artifacts repo; downloaded artifacts satisfy the same build requirements as zig-built artifacts so `tclsh src/make.tcl project` succeeds with downloaded artifacts in place of built ones.
### G-007 [active] Location-transparent punk::console across repl and code interps
### G-007 [achieved 2026-07-05] Location-transparent punk::console across repl and code interps
Scope: src/modules/punk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-999999.0a1.0.tm
Detail: goals/G-007-console-location-transparency.md
Goal: punk::console presents one API in every interp/thread of a punk session - code-interp callers see the same console facts and can perform the same queries/operations as the parent, routed to the console-owning context via a punk-side ownership registry, with no `repl eval` required.
@ -109,3 +109,116 @@ Scope: src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-9
Detail: goals/G-010-subshell-tree-navigation.md
Goal: subshells form a navigable tree - a subshell can be suspended rather than quit, listed, resumed, and the console switched to any live subshell in the tree (e.g. grandchild to grandparent, or across branches) with each subshell's console state re-applied via its G-008 state set, building on the non-nested subshell model of G-002.
Acceptance: from a grandchild subshell a single switch command reaches the grandparent without unwinding through the intermediate parent; switching between subshells on different branches preserves each subshell's session state and re-applies its console state set on activation; suspended subshells can be listed and resumed; `quit` still unwinds to the launching parent as today; a subshell whose switch commands are hidden/restricted cannot initiate switches.
### G-011 [proposed] Optional per-console err channel with defined stderr semantics
Scope: src/modules/punk/console-999999.0a1.0.tm, src/modules/opunk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm
Detail: goals/G-011-console-stderr-semantics.md
Goal: a console optionally carries an err channel as an attribute of its canonical {in out} identity - {in out err} specs accepted everywhere -console is, err resolving to process stderr for the default console and to the console's out channel elsewhere - so diagnostics and emit-to-err are first-class per-console operations instead of raw puts stderr.
Acceptance: console_spec_resolve and every -console site accept an {in out err} spec (err optional; existing pair/instance-name/object spec forms unchanged); opunk::Console exposes the err channel (nullable, additive base-class change); an unset err resolves to stderr for the default console and to the console's own out channel otherwise; punk::console's own warnings/diagnostics emitted while operating on a resolvable console go to that console's err (raw puts stderr remains only where no console is in play); an emit-to-err path exists and is exercised by at least one real consumer (e.g punk::repl); the effective err is discoverable from any thread/interp via console_fact_get (fact key err, returning the effective err channel name); ownership/fact/mode-cache keys remain canonical {in out}; the existing console test suites pass unchanged.
### G-012 [proposed] Template system: inert VCS-config payloads and explicit layout refresh
Scope: src/project_layouts/, src/make.tcl, src/modules/punk/mix/ (layout instantiation), fauxlink module (bootsupport 0.1.1 - promoted if chosen as mechanism)
Detail: goals/G-012-template-payload-safety.md
Goal: project layouts carry no live nested VCS-config files - template .gitignore payloads are stored inert (renamed, or fauxlink-encoded) and materialized at project generation - and src/make.tcl has an explicit punkcheck-tracked step that refreshes layout payloads from their canonical sources.
Acceptance: a scan of src/project_layouts finds no file named .gitignore, and git check-ignore --no-index over every layout file matches only root-.gitignore rules (nested rules provably inert); a project generated from each affected layout receives a working .gitignore whose content matches the canonical payload/target; after editing a canonical source (e.g. root .gitignore), the make.tcl template-refresh step updates the derived layout payloads (punkcheck-tracked), covering vendor/punk layouts as well as custom/_project; the previously hidden template files (layout READMEs, vendored TODO-class files) remain git-tracked without per-file force-add exceptions.
### G-013 [proposed] Raw mode as the repl's default input mode
Scope: src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/console-999999.0a1.0.tm
Detail: goals/G-013-raw-mode-default.md
Goal: a repl launched without explicit mode configuration starts in raw input mode with a clean display - every per-keystroke debug emission behind its own repl-operable toggle - and raw editing covers the line-mode essentials (history navigation, cursor movement).
Acceptance: a default launch lands in raw mode with line mode still selectable; with debug toggles at their defaults (off unless stored configuration via ::punk::config says otherwise - see G-014), typing/editing/submitting a command emits no cursor-positioned debug output; the per-keystroke add_chunk frame and the right-hand live editbuf view are gated separately, each toggleable from within a running repl; arrow-key history navigation and left/right cursor movement work in raw mode (current stubs replaced); the marked-line debugrepl output form is retained and works on terminals without cursor addressing (e.g. vt52); the debugrepl first-word activation mechanism is reviewed and the keep/replace outcome (e.g. a proper Tcl command that interp hide can restrict in subshells) is recorded in the detail file; on tcl 8.6 a background-initiated terminal query at an idle raw-mode prompt succeeds (the residue scenario fail-fast-guarded in punk::console 0.7.1).
### G-014 [proposed] ::punk::config stored configuration: toml files with named-subshell scoping
Scope: src/modules/punk/config-0.1.tm, src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-999999.0a1.0.tm, src/vendormodules/tomlish-*.tm (vendored; canonical source in the external tomlish project space)
Detail: goals/G-014-punk-config-toml.md
Goal: ::punk::config loads stored configuration from toml files in the XDG-located config dir - parsed via the vendored tomlish module, never an ad-hoc parser - and consumers resolve settings with per-named-subshell overrides, so features like the G-013 debug-view startup defaults read declared user configuration instead of hardcoded fallbacks.
Acceptance: a setting declared in a toml file under the XDG-located config dir is visible through the punk::config API at repl startup, and with no config files present built-in defaults apply with no errors beyond the existing missing-dir notice; a named subshell resolves its own overriding value for a key also defined at the parent/default scope, and a subshell with no override inherits the outer value (proven with at least one real key); at least one shipped feature (the G-013 editbuf-view startup default is the natural first) reads its default through this path rather than a hardcoded value; all toml reading/writing in punk::config goes through the tomlish module, and the tomlish API procs punk::config consumes carry punk::args (PUNKARGS) documentation - added upstream in the tomlish project and re-vendored here before punk::config implementation proceeds.
### G-015 [proposed] Punk executable `script` subcommand: reliable non-interactive piped/script execution
Scope: src/vfs/_config/punk_main.tcl, src/lib/app-punkshell/punkshell.tcl (script path or a leaner dedicated app package)
Detail: goals/G-015-script-subcommand-piped-stdin.md
Goal: `<punkexe> script [<scriptname>] [<args>...]` executes a script file or piped stdin content (scriptname optional when input is piped) in an interp preloaded with the basic punk modules and aliases a punk shell provides by default, and always terminates with the script's success/failure as its exit code - without the `shell` subcommand's shellfilter channel transforms/logging stacks and without ever dropping into an interactive shell - so agents can reliably make piped script calls to punk executables.
Acceptance: piping commands to `<punkexe> script` runs them and terminates at stdin EOF with no trailing `exit` required, exit code 0 on success; a failing piped command terminates the process with a nonzero exit code and the error on stderr, never landing in an interactive shell regardless of console availability or PUNK_PIPE_EOF; `<punkexe> script <file> [<args>...]` executes the file with conventional ::argv0/::argv and propagates its error status the same way; the script path installs none of the `shell` subcommand's shellfilter stacks/transforms and the launch plumbing itself emits nothing on stdout/stderr (the current stub's stderr diagnostics removed) so exec-style callers see only the script's own output; the motivating example works with no package require boilerplate: piping `dev projects.work *<name>*` to `<punkexe> script` emits the matching-project table and exits 0, because the script interp carries the default punk shell module/alias environment.
### G-016 [proposed] `projects.work` discovers git-based projects alongside fossil
Scope: src/modules/punk/mix/commandset/project-999999.0a1.0.tm, src/modules/punk/repo-999999.0a1.0.tm
Detail: goals/G-016-projects-work-git-discovery.md
Goal: `dev projects.work <glob>` lists git-based project checkouts as well as fossil-based ones, each result identifying its VCS - fossil discovery stays central-config-db based, and git discovery uses a defined enumeration source (git has no central registry; the chosen mechanism is recorded in the detail file).
Acceptance: with a git-only project on disk registered in the chosen enumeration source and matching the glob, `projects.work` lists its working directory and identifies it as git; existing fossil results are unchanged apart from any added VCS-identifying column; a project that is both git and fossil (e.g. this repo) appears with both indicated rather than duplicated; glob matching remains case-insensitive.
### G-017 [proposed] Agents locate local projects via piped `projects.work` calls, not filesystem scanning
Scope: AGENTS.md (root) or a child doc it indexes (guidance content only - no code)
Goal: once G-015 makes piped script calls reliable, repository guidance directs agents asked to locate another local project to query it via a piped `projects.work` call to a punk executable instead of grepping/globbing the wider filesystem.
Acceptance: root AGENTS.md (or a child doc indexed from it) records the exact recommended invocation - executable, subcommand, glob usage, expected output shape - and states when filesystem scanning remains appropriate (projects not registered in any discovery source); the guidance is added only after G-015 is achieved (and notes the fossil-only limitation until G-016); following the documented pattern, an agent locates a named sibling project's checkout dir with a single piped call.
### G-018 [proposed] Zig-built plain tclsh kits: self-contained zip-based executables without punk infrastructure
Scope: build.zig / build.zig.zon (per G-005), src/runtime/, src/make.tcl integration
Detail: goals/G-018-zig-plain-tclsh-kits.md
Goal: developers can use the G-005 zig build system to produce self-contained zip-based tclsh executables that carry no punk-specific infrastructure (no punk boot layer, punk modules, or punk apps) - plain tclsh kits usable independently of the punkshell product.
Acceptance: a documented zig invocation on a clean checkout (zig toolchain available) produces a zip-based tclsh executable for at least one target platform that runs conventional tclsh invocations (`<exe> script.tcl args`, piped stdin) on a machine with no Tcl installation; a listing of the kit's mounted/zip contents shows stock Tcl (plus any declared stock runtime deps) and no punk namespaces, punk boot files, or punkshell apps; the punk-flavoured executables remain producible alongside.
### G-019 [proposed] Dependency-scan-driven module trimming for punk-based executables
Scope: src/make.tcl, src/modules/punk/lib-999999.0a1.0.tm (tclparser use), src/vfs/ (kit assembly), scanning module (new or existing punk module - to be determined)
Detail: goals/G-019-dependency-scan-module-trimming.md
Goal: a package-dependency scan from an executable's entrypoint (candidate basis: the tclparser parse API - currently satisfied only by the c-only tclparser library, with punk::lib's pure-Tcl fallback an unimplemented stub) determines the module closure the executable actually requires, so a build can ship only those modules - while 'batteries included' builds remain a supported alternative, not a casualty.
Acceptance: for at least one punk-based executable target, the build can run a dependency scan from its entrypoint producing the closure of required packages/modules plus a mechanism to declare dynamically-loaded extras the scan cannot see; a trimmed kit assembled from that closure starts and passes its basic function check (e.g. repl launch or the app's smoke test) with no missing-package errors; the trimmed kit's module listing is a strict subset of the batteries-included equivalent (demonstrating real exclusion); batteries-included builds remain producible unchanged.
### G-020 [proposed] Screen capture and input injection module with per-platform backends (Windows first)
Scope: src/modules/punk/ (new module - name TBD), src/vfs/punk9win.vfs/lib_tcl9/ (existing treectrl/Img/twapi payloads); scriptlib/aloupe.tcl stays untouched as a standalone app
Detail: goals/G-020-screencap-input-module.md
Goal: a punk module drives screen/window capture and mouse/keyboard injection from scripts via per-platform backends - Windows (treectrl loupe capture + twapi input/window-location) is the initial complete target, with the backend contract designed so X11 (Linux/FreeBSD) and macOS backends can be added without changing callers, and Wayland-native sessions explicitly out of scope.
Acceptance: on Windows from a punk shell or script: a screen region and a window located by title/class pattern are each captured to a Tk photo and written as a valid PNG; mouse movement/click and key events injected into a located test window produce their observable effect (typed text arrives, click acts); window location returns the handle and geometry for a pattern; a capability-introspection call reports per-feature support and an unsupported platform/backend yields a clean capability-based refusal, not a crash; the backend interface (capture / input / window-locate) is documented well enough that a non-Windows backend can be added without modifying callers; the aloupe script remains functional and unmodified.
### G-021 [proposed] Agent-drivable visual/UI verification via piped snapshot and interaction calls
Scope: src/modules/punk/ (G-020 module's agent-facing surface), AGENTS.md guidance (post G-015 pattern), src/tests/ (visual-verification test hooks)
Detail: goals/G-021-agent-visual-verification.md
Goal: a tool-calling agent can, during a session, use piped script calls (G-015) to a punk executable to locate the applicable UI window, snapshot it to a PNG file and/or base64 output suitable for AI image analysis, and drive mouse/keyboard interactions - enabling tests whose verification is visual-only and/or input-driven.
Acceptance: on Windows, single piped script calls (no interactive session) can: list/match windows for a pattern with machine-parseable output; save a located window's snapshot to a caller-specified path and optionally emit it base64 on stdout; run a scripted interaction sequence (focus, click at offset, type text, snapshot) end-to-end; failures exit nonzero with the error on stderr per G-015 semantics; the invocation patterns are documented for agents alongside the G-017 guidance; at least one real visual-or-input-driven verification (e.g. a Tk app smoke test) is exercised through this path.
### G-022 [proposed] Scriptable safe fossil move/rename in `dev repo`; rename this project's fossil repo to punkshell
Scope: src/modules/punk/mix/commandset/repo-999999.0a1.0.tm, src/modules/punk/repo-999999.0a1.0.tm, src/tests/modules/punk/mix/testsuites/repo/
Detail: goals/G-022-fossil-rename-punkshell.md
Goal: the `dev repo` commandset can move and rename fossil repositories non-interactively and safely - all checkouts repointed, no phantom central config-db entries, no dangling old repo db, fossil project-name renamable with project-code unchanged - and this project's fossil repo (currently project-name 'shellspy') is renamed to 'punkshell' through that mechanism via a G-015 piped script call, not by hand.
Acceptance: the commandset provides a flag-driven (no stdin prompts) move/rename operation which on a scratch repo with an open checkout: repoints every registered checkout, leaves the central config-db listing only the new path, removes or archives the old repo db file (per option), clears stale ckout: back-references, and applies a requested project-name change while preserving project-code; the GAP characterization tests in src/tests/modules/punk/mix/testsuites/repo/fossilmove.test are updated to assert the clean behaviour and pass; after G-015 is achieved, this repo's fossil db (shellspy.fossil / project-name shellspy) is renamed to punkshell via the new operation invoked through a piped `script` call, with `fossil info` in this checkout showing the new repository path and project-name and `fossil all ls` free of the old path.
### G-023 [proposed] Version-named punk binaries per Tcl generation (versioned / dev / release-gated plain names)
Scope: src/make.tcl, src/runtime/ (mapping config - see G-024), bin/ (build outputs)
Detail: goals/G-023-version-named-binaries.md
Goal: project builds produce version-named punk executables for tcl 8.6 and tcl 9 as the project version advances - punk8-<major>-<minor>-<patch>.exe / punk9-<major>-<minor>-<patch>.exe per version, punk8-dev.exe / punk9-dev.exe tracking the latest build, and plain punk8.exe / punk9.exe created initially then replaced only when an actual release is tagged - tolerating the disk growth for now.
Acceptance: a project build at the current punkproject.toml version produces punk8-<M>-<m>-<p>.exe and punk9-<M>-<m>-<p>.exe (names derived from the version, not hand-maintained) plus punk8-dev.exe / punk9-dev.exe updated to that same build; rebuilding at an unchanged version refreshes that version's binaries and -dev without touching other versions' outputs; plain punk8.exe / punk9.exe exist and are replaced only by an explicit release step - a normal build never overwrites them; the scheme is declared succinctly via the G-024 toml mapping (no per-version config edits); archival/deletion of accumulated versioned binaries is out of scope with the trigger question recorded in the detail file.
### G-024 [proposed] mapvfs.config converted to toml (tomlish-parsed) with succinct scheme declarations
Scope: src/runtime/mapvfs.config (replaced/deprecated), src/make.tcl (parsing), src/bootsupport/modules/tomlish-*.tm (parser dependency)
Detail: goals/G-024-mapvfs-toml.md
Goal: the runtime-to-vfs-to-executable build mapping moves from the custom line format of src/runtime/mapvfs.config to a toml file parsed with the tomlish package - still supporting explicit per-executable mappings (runtime, vfs folder, output name, kit type) while also expressing generative schemes like the G-023 versioned naming in a single succinct declaration.
Acceptance: a mapvfs toml file parsed via tomlish (no ad-hoc toml parsing) drives the build: every mapping currently active in mapvfs.config is expressible and at least one existing target builds identically from the toml; the G-023 versioned/dev/release-gated output scheme is declared in one entry that expands to its outputs without enumerating versions; malformed or unresolvable entries fail the build with a clear message naming the entry; the legacy .config format is either fully migrated (old parser removed) or explicitly deprecated with documented precedence between the two files.
### G-025 [proposed] Punk executables self-report project version and build provenance
Scope: src/vfs/_config/punk_main.tcl (subcommand dispatch), src/make.tcl (stamping build info into the vfs), src/vfs/ (stamp payload location), src/modules/punk/ (in-shell command - the single implementation)
Detail: goals/G-025-exe-selfreport.md
Goal: a punk executable reports its identity from embedded data rather than its filename - a documented subcommand prints the punkproject.toml project version it was built from plus the input runtime binary name and vfs folder name used to assemble it - with the same-named command available in the punk module so scripts running in any punk shell (including tclsh-hosted ones like `tclsh src/make.tcl shell`) get the same report in-process without exec, stamp fields reported as absent rather than fabricated when there is no stamp.
Acceptance: the build stamps project version, runtime binary name, and vfs folder name into the kit; the built executable invoked with the version-report subcommand prints those fields machine-parseably on stdout and exits 0 with no other output (G-015-compatible; no repl fallthrough); a same-named command in the punk module returns the same fields in-process (subcommand implemented as a wrapper over it - one implementation) and works from the code interp; the report distinguishes stamped provenance from live facts: a stamped kit reports its stamp, a `src`-mode or source-tree session additionally reports the live punkproject.toml version as a distinct field when it differs, and unstamped contexts (`tclsh src/make.tcl shell`, plain tclsh with punk modules) report stamp fields explicitly absent with live runtime facts (actual `info nameofexecutable`) still provided; the report is correct when the executable file has been renamed or copied; executables built before stamping existed fail gracefully with a clear message rather than fabricating values.
### G-026 [proposed] Enforceable clean-checkout provenance policy for vendor and bootsupport pulls
Scope: src/make.tcl (vendorupdate and bootsupport steps), src/vendormodules/include_modules.config, src/bootsupport/modules*/include_modules.config
Detail: goals/G-026-vendor-provenance-policy.md
Goal: pulling vendored or bootsupport artifacts from local source projects enforces committed provenance - the warn-only dirty-checkout check added to vendorupdate in project 0.2.5 becomes a policy that can abort with an explicit override, covers the bootsupport update path as well, and the residual staleness question (built modules that predate or postdate the committed source even in a clean checkout) has a recorded design decision.
Acceptance: vendorupdate and the bootsupport update refuse to pull from a source project whose fossil/git checkout is dirty unless an explicit documented override is given (warn-only selectable as a configured mode); the check reports each VCS root once per run and does not fire for unversioned source locations; bootsupport_localupdate is covered by the same shared check (no second divergent implementation); the staleness gap - a clean checkout whose built modules/ artifacts do not correspond to the committed source - is either detected (mechanism chosen and implemented) or explicitly recorded in the detail file as accepted risk with the considered mechanisms; behaviour is exercised by a test or documented manual verification against a scratch dirty checkout.
### G-027 [proposed] Pull-based infrastructure updates for punkshell-derived projects
Scope: src/modules/punk/mix/commandset/project-999999.0a1.0.tm (project.new push path), src/modules/punkcheck-999999.0a1.0.tm (install provenance records), src/make.tcl (derived-project pull entrypoint)
Detail: goals/G-027-derived-project-pull-updates.md
Goal: a project generated from a punkshell layout can pull infrastructure updates (make.tcl/build.tcl, bootsupport modules and libs, layout template payloads) from its originating punkshell project by running one command inside the derived project - replacing the current push model (`dev project.new -force 1 -update 1` run from punkshell) - with install provenance robust to derived-project workdir moves (not local relative paths alone), VCS-state awareness on both ends, and a recorded decision on pulling from remote sources.
Acceptance: one documented command run inside a derived project updates its punkshell-derived infrastructure from the source punkshell project; the update still works after the derived project's working directory has been moved (proven by moving a scratch derived project and pulling); VCS integration on both ends: the pull applies the G-026 clean-checkout policy to the punkshell source, and reports (or refuses per option) when target files it would overwrite carry uncommitted local modifications in the derived project's git/fossil checkout; .punkcheck records remain the provenance basis (updates are recorded and unchanged targets skipped, as with existing punkcheck-tracked installs); the push flow keeps working until explicitly retired; the remote-pull question (updating from a remote punkshell repository rather than a local checkout) has a recorded design decision - implemented, or deferred with rationale in the detail file.
### G-028 [proposed] Name the process locking a file when builds cannot replace a target
Scope: src/modules/punkboot/utils-999999.0a1.0.tm (locker-report helper), src/make.tcl (kit deploy failure reporting)
Detail: goals/G-028-file-locker-identification.md
Goal: when a build cannot delete/overwrite a target file (typically a built executable held open by another program), the failure message names the locking process(es) - via a punkboot::utils helper using the Windows Restart Manager API through optionally-available twapi or cffi, degrading cleanly to the current message when the API or bindings are unavailable or on other platforms.
Acceptance: on Windows with twapi or cffi loadable, a punkboot::utils proc given a file path returns the locking processes (at least pid and process name; empty list when unlocked); punkboot::utils itself stays pure Tcl - the binary binding is required lazily at call time and its absence yields a clean 'unavailable' result, not an error (bootsupport must not gain a compiled-extension dependency); make.tcl kit deploy failures include the locker report when determinable (e.g. "could not delete target binary ... in use by: 7zFM.exe (pid 1234)"); non-Windows platforms and binding-less environments produce the existing message unchanged; verified against a deliberately held handle (documented manual verification acceptable).
### G-029 [proposed] Build packaged test::<modulename> #modpod modules from src/tests
Scope: src/make.tcl (test-module packaging step), src/tests/ (source of truth), src/modules/test/ (generated #modpod targets), src/modules/punk/mix/ (modpod tooling as needed)
Detail: goals/G-029-testmodules-from-srctests.md
Goal: src/tests is the single source of truth for module test suites - a punkcheck-tracked make.tcl step generates the packaged test::<modulename> #modpod modules from src/tests/modules/<namespacepath>/testsuites content, ending hand-maintenance of parallel copies under src/modules/test/; the packaged form is a distributable in its own right - a user who downloads a built module can optionally download the matching test::<modulename> and verify the module's behaviour on their own system (package require + RUN, or an executable's -app test) with no source tree or test harness required.
Acceptance: a make.tcl step generates a test::<modulename> #modpod under src/modules/test/ from the corresponding src/tests testsuites (punkcheck-tracked, skipped when sources unchanged); the generated package works through the packaged path - loadable via package require test::<modulename> and runnable via its SUITE/RUN interface (e.g a built executable's -app test) - reporting the same test names and pass counts as running the same suites directly via src/tests/runtests.tcl; the consumer scenario is proven standalone: the generated package (plus the module under test and their dependencies) runs in an environment without the project source tree present; suite data files (e.g roundtrip toml files with deliberate crlf/mixed line endings) survive packaging byte-for-byte; documentation states src/tests is the source of truth and the generated modpods are build artifacts not to be hand-edited; proven end-to-end for at least one real module (tomlish, whose src/tests port and still-live modpod created the dual-copy situation, is the natural first).

32
bin/AGENTS.md

@ -0,0 +1,32 @@
# bin/
## Purpose
Built punk shell executables (kits with the punk boot layer), assorted build/experiment tooling, and plain runtime kits under `runtime/`. Executables here are build outputs - agents do not hand-edit binaries.
## Local Contracts
### Launch package modes (built punk shells)
The first argument to a built punk shell may be a dash-delimited package mode composed of tokens from `dev`, `os`, `src`, `internal` (e.g. `punksys src`, `punk902z dev-os`). `internal` is the default and is always appended when absent. A first argument that is not a valid mode list is treated as a subcommand instead. Implementation: `src/vfs/_config/punk_main.tcl` (search `all_package_modes`).
`src` mode is the one that matters for verifying working-tree changes:
- Discovers the project root from the executable's location (exe in `bin/` -> parent directory), not from the cwd - it works from any working directory as long as the executable resides in the project's `bin/`.
- Prepends `src/modules`, `src/modules_tcl<N>`, `src/bootsupport/modules{,_tcl<N>}` and `src/vendormodules{,_tcl<N>}` to the module path, sets `package prefer latest` so dev-numbered `999999.0a1.0` source modules beat kit-stamped snapshots on unversioned `package require`, and registers `#modpod` modules from `src/modules` (startup notice: `src mode: registered N #modpod modules from .../src/modules`).
- Consequence: `punksys src` / `punk902z src` exercises current working-tree source with no kit rebuild; a plain launch (`punksys`) exercises the module snapshots baked into the kit at build time. `package present <pkg>` reporting `999999.0a1.0` (vs a release version like `0.7.1`) tells you which set a session is running - runbooks with version expectations must state the intended launch mode.
### Interactive verification shells
- Interactive console/repl verification should cover both Tcl generations - behaviour can differ materially (e.g. the Tcl 8.6 windows console channel driver vs the Tcl 9 rewrite). Use a Tcl 8.6-based punk shell (`punksys.exe`) and a current Tcl 9-based punk shell (named for the Tcl release it embeds, e.g. `punk902z.exe` at the time of writing - ask the user which is current rather than assuming). `info patchlevel` in-session confirms the runtime.
- `runtime/win32-x86_64/` holds plain tclkits/tclsh runtimes without the punk boot layer - use these for clean-environment probes isolating Tcl-level behaviour from punk (they may lack extensions such as twapi; add an external lib dir to `auto_path` when a probe needs one).
## Work Guidance
## Verification
None - build outputs; behaviour is verified via `src/tests/` and interactive runbooks.
## Child DOX Index
- `runtime/win32-x86_64/` - plain runtime kits (no child AGENTS.md needed; covered by this file's contracts)

43
goals/G-007-console-location-transparency.md

@ -1,6 +1,6 @@
# G-007 Location-transparent punk::console across repl and code interps
Status: active
Status: achieved 2026-07-05
Scope: src/modules/punk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-999999.0a1.0.tm
Acceptance: from a running punk session's code interp, without `repl eval`: `console_fact_get` returns the same values the parent sees and a fact set in the parent is immediately visible; a terminal query (e.g. `get_cursor_pos` or `dec_get_mode`) against the default console succeeds and cooperates with the repl reader (no lost or garbled input); a console constructed and owned by code-interp code is operated on locally (no round-trip to the parent); the existing console test suites pass and single-interp (non-repl) usage is unchanged.
@ -91,6 +91,16 @@ already lives in tsv because it must be visible to all threads.
## Notes
- **Acceptance verified interactively 2026-07-05** on punksys (tcl 8.6) and punk902z (tcl 9),
built kits (punk::console 0.7.0, opunk::console 0.4.0; punk902z also rerun against dev
modules via `src` launch): fact parity and immediate cross-context visibility in both
directions; brokered terminal queries (get_cursor_pos, dec_get_mode) from the code interp
succeed and cooperate with the repl reader in raw mode with typed-ahead input preserved
intact; a chan-pipe console anchored from the code interp registers its anchoring codethread
as owner, routes nowhere (local operation), and clears its registry entry on forget. Test
suites had already passed non-interactively (ownerrouting.test et al, baseline exec-14.3
only).
- **Not blocked by G-002** - deliberately sequenced before it. A v1 with a single fixed owner
(the repl thread) is definable now and is the mechanism G-002's "target a named console"
criterion generalises to multiple owners. Deferred to G-002: multi-owner topology,
@ -104,3 +114,34 @@ already lives in tsv because it must be visible to all threads.
preserve that property (or use async+vwait) when generalising.
- The choke points listed in Context are the intended shim set; anything routed above them
(the ~60 public procs) inherits brokering for free.
- **Line-mode reader cooperation - characterized interactively (punk902z tcl9 + punksys tcl8.6,
2026-07-05), no routing regression.** Brokered (code-interp) and owner-side (`repl eval`)
queries behave identically in line mode on both runtimes, satisfying the location-transparency
intent; the limitations below are properties of line mode's interaction with the query
raw-window cycling (the `#review`-flagged line-mode `input_chunks_waiting` path in
punk::repl), not artifacts of G-007 brokering, and are out of scope here:
- *Complete* typed-ahead lines survive: consumed during the raw window, stashed to
`input_chunks_waiting`, recovered and executed after the loop via the line-mode
waiting-chunks path (accompanied by the red "chan blocked is true" diagnostic and echo
interleaving - cosmetic only, no loss).
- *Incomplete* typed-ahead (characters pending without Enter) breaks the query cycle: the
in-flight cooked-mode read starves the raw-window response read (per-query timeouts,
~560ms), and the responses later echo outside any read window and are submitted to the
line parser as phantom input (e.g. `invalid command name 60`).
- On tcl 8.6 (punksys), *every* line-mode query initially failed the same way - owner-side
included, no routing involved. Root-caused (2026-07-05, empirically against clean tclkits
8.6.13/8.6.17): 8.6 console channels expose no -inputmode configure key, so three
punk::console guards misclassified a real 8.6 console as a pipe; fatally, input_at_eof's
pipe-branch probe read ran on the drained console channel immediately before each query's
raw cycle, making the 8.6 channel driver park a blocking cooked-mode ReadConsole (driver
reads sample the console mode at issue time - a later raw flip cannot rescue them) that
swallowed the query response until Enter. Fixed in punk::console 0.7.1 by treating a twapi
console handle for stdin as definitive console detection at all three sites (see
console-buildversion.txt). Queries issued while the repl reader is armed and idle at a
line-mode prompt (e.g. from background after-scripts) remain subject to the parked-read
limitation on 8.6 - raw mode, or the raw-default roadmap, covers that residue.
- Raw mode (the repl's own editor) cooperates cleanly in both directions - queries succeed
and typed-ahead is preserved intact. Line mode is today's default (raw is the intended
future default); fixing line-mode query cooperation would be its own goal.
Related: both diagnostics observed (the query-timeout stderr message, the "chan blocked"
notice) are the kind of raw diagnostic traffic G-011 gives a defined per-console home.

107
goals/G-011-console-stderr-semantics.md

@ -0,0 +1,107 @@
# G-011 Optional per-console err channel with defined stderr semantics
Status: proposed
Scope: src/modules/punk/console-999999.0a1.0.tm, src/modules/opunk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm
Acceptance: console_spec_resolve and every -console site accept an {in out err} spec (err optional; existing pair/instance-name/object spec forms unchanged); opunk::Console exposes the err channel (nullable, additive base-class change); an unset err resolves to stderr for the default console and to the console's own out channel otherwise; punk::console's own warnings/diagnostics emitted while operating on a resolvable console go to that console's err (raw puts stderr remains only where no console is in play); an emit-to-err path exists and is exercised by at least one real consumer (e.g punk::repl); the effective err is discoverable from any thread/interp via console_fact_get (fact key err, returning the effective err channel name); ownership/fact/mode-cache keys remain canonical {in out}; the existing console test suites pass unchanged.
## Context
punk::console models exactly two channels per console: {in out}, defaulting to {stdin stdout}.
For pure ANSI terminal control that is a faithful model - queries and responses share one
terminal connection by protocol design - but the goals collectively move punk::console from
"terminal control library" toward "session-wide console abstraction" (G-001 pluggable backends,
G-007 location transparency, G-008 scoped state, G-009 themed profiles), and there the missing
stderr stream becomes load-bearing:
- **Error/diagnostic routing is ad-hoc.** punk::console's own warnings (enableRaw/disableRaw
failures, query timeouts, reader-cooperation warnings, test_char_width failures - dozens of
sites) go to raw `puts stderr`, bypassing the abstraction the module provides. A console
backed by an ssh channel or tk widget (G-001) has no principled way to receive its own error
output: backend authors must either merge stderr into stdout or bypass punk::console for
errors, fragmenting the abstraction.
- **No console-level separation of result vs diagnostic streams.** The shellrun
run/runout/runerr/runx family preserves the stdout/stderr distinction at the process layer,
but once output enters the console the distinction collapses into the single out channel. A
subshell wanting to display a process's stderr differently (colour, prefix, separate pane)
must do so outside the console model - and a G-009 theme has no stderr stream to style.
- **Cross-interp discovery has no slot.** The G-007 fact store and ownership registry are keyed
by canonical {in out}; "what is my console's stderr?" has no fact to read, so location
transparency covers stdout-shaped operations only.
- **Raw-mode restore is asymmetric.** `previous_stty_state_stderr` is tracked for stty-state
restoration, but the model gives no way to address stderr as a console channel.
## Approach
**err is an attribute of a console, not part of its identity.** Console identity - the key for
the ownership registry (punk_console_owners), fact store (punk_console_facts) and mode cache
(punk_console_modecache) - remains the canonical {in out} pair: two consoles differing only in
err target are the same terminal, and keeping the key stable means the G-007 slices are
untouched.
1. **Spec grammar.** console_spec_resolve (and the -console option everywhere) accepts a
3-element {in out err} list. The 3-element slot is free in the existing grammar (pairs are
llength 2, instance names 1, voo object values >= 7). Existing spec forms are unchanged.
2. **Object layer.** ::opunk::Console gains a nullable err field beside o_in/o_out (additive
base-class change; subclasses without a stderr-equivalent leave it unset).
3. **Resolution defaults.** An unset err resolves to process stderr for the default console
{stdin stdout} (matching where its diagnostics go today) and to the console's own out
channel for any other console (single-stream backends merge, losing nothing they had).
4. **Diagnostics routing.** punk::console warnings/diagnostics raised while operating on a
resolvable console are emitted to that console's effective err. Raw `puts stderr` remains
only for contexts where no console is in play (e.g module-load-time warnings).
5. **Emit path.** A first-class emit-to-err form (shape decided at implementation time - e.g an
err-target variant of the opt_console_out convention) exercised by at least one real
consumer such as punk::repl. The two consumer-shaped clauses divide deliberately: the
internal diagnostics migration (item 4) proves the abstraction serves its own first
consumer and is self-contained; the external consumer proves the *public* emit form is
usable by subshells wanting to present stderr distinctly.
6. **Cross-thread discovery.** The effective err is exposed through console_fact_get under
fact key `err`, returning the effective err channel name, so any thread/interp of a
session can answer "where do this console's diagnostics go" (G-007 location transparency
extends to the err attribute).
## Alternatives considered
- **err in the identity key: canonical {in out err} triple** - rejected: conflates routing with
identity (a console does not become a different terminal when its diagnostic target changes),
and churns the G-007 registry/fact/cache keying and its tests for no modelling gain.
- **Scope-boundary documentation only (no third channel)** - rejected by the user for subshell
flexibility: G-001 backends and G-009 themes need a distinct diagnostic stream to be
first-class, and a parallel unmanaged diagnostics mechanism would fragment the abstraction.
(Had punk::console stayed a pure terminal-control library, this would have been the right
answer - the dual-channel model is correct for ANSI control itself.)
- **Uniform default err == out (including the default console)** - rejected: silently changes
where the default console's diagnostics land today (process stderr), breaking redirection
and 2>-style capture users rely on.
- **Unset err means none (error on emit-to-err)** - rejected: pushes fallback burden onto every
caller and makes diagnostics emission failure-prone exactly where it must be reliable.
## Notes
- **The "-console surface" is the choke points, not per-site edits.** The module's documented
convention already funnels every -console site through a small resolution set:
console_spec_resolve, internal::hybrid_console_spec, internal::opt_console_out /
opt_console_channels (+_var variants), internal::spec_to_channelpair, and the shared PUNKARGS
fragments ::punk::console::argdoc::console_opts / console_emit_opts. Accepting the 3-element
form there satisfies "every -console site" by construction; a site that bypasses these
helpers is already a convention violation to be fixed on discovery, not a reason to audit
the whole module up front.
- **Channel-name locality.** Like in/out, an err channel name is thread-local for non-std
channels; the fact-store exposure inherits the owner-qualified keying that already handles
this for other facts. What is discoverable cross-thread is the owner-context name (and the
default-console case, where std names are process-wide).
- **G-001 interaction.** G-001's acceptance requires the base class unchanged *by G-001's own
work*; the nullable err field is an additive change made by this goal. If G-011 lands first,
G-001 subclasses simply inherit the field; sequencing either way is safe. Backend authors
should note the default is "merge into out": a backend with a genuinely distinct diagnostic
stream (e.g a separate ssh extended-data channel or a dedicated tk widget pane) must set err
explicitly at construction to get separation.
- **previous_stty_state_stderr stays as-is.** It preserves stty state of the process stderr fd
across raw-mode cycling - terminal-mode restoration, orthogonal to diagnostic output routing.
This goal adds nothing to it and removes nothing from it.
- **G-008/G-009 interaction.** Once err exists, a per-subshell re-route of diagnostics is
console state that a G-008 state set could capture; that is enrichment for those goals, not
part of this goal's acceptance.
- **Not in scope.** Channel transforms/filtering on err (shellfilter stacks), a separate err
reader, and any change to shellrun's process-layer stdout/stderr handling - this goal defines
the console-side slot they can target.

80
goals/G-012-template-payload-safety.md

@ -0,0 +1,80 @@
# G-012 Template system: inert VCS-config payloads and explicit layout refresh
Status: proposed
Scope: src/project_layouts/, src/make.tcl, src/modules/punk/mix/ (layout instantiation), fauxlink module (bootsupport 0.1.1 - promoted if chosen as mechanism)
Acceptance: a scan of src/project_layouts finds no file named .gitignore, and git check-ignore --no-index over every layout file matches only root-.gitignore rules (nested rules provably inert); a project generated from each affected layout receives a working .gitignore whose content matches the canonical payload/target; after editing a canonical source (e.g. root .gitignore), the make.tcl template-refresh step updates the derived layout payloads (punkcheck-tracked), covering vendor/punk layouts as well as custom/_project; the previously hidden template files (layout READMEs, vendored TODO-class files) remain git-tracked without per-file force-add exceptions.
## Context
Project layouts under `src/project_layouts/` carry `.gitignore` files as *payload* - the ignore
file a generated project is supposed to receive (essentially a copy of shellspy's own root
`.gitignore`). But git has no concept of payload: any `.gitignore` anywhere in the working tree
is live configuration for its subtree, with leading-`/` patterns anchored to the nested file's
own directory. The result is a template that self-censors - the layout ignores, in the shellspy
repo, exactly what a generated project would ignore in itself.
Discovered 2026-07-06 during git/fossil tracked-set verification (fossil reads only the
checkout-root `.fossil-settings/ignore-glob`, so it managed template files git silently
dropped): four `vendor/punk/*` layouts each carry a live `.gitignore`, and
`.../project-0.1/{bin,lib,modules}/README.md` plus `bin/runtime/README.md` were untracked in
git. The `bin/runtime` case shows the nastier variant - git does not descend into an excluded
directory, so negation patterns cannot resurrect deeper paths. The immediate stopgap was
per-file `git add -f` (commit fc1c474c), which protects only existing files: any new file
placed in a template's ignored spots drops silently again. The dual-VCS contract
(.fossil-settings/AGENTS.md) records the git/fossil asymmetry and points here for the fix.
A secondary weakness: template payloads that mirror living files (the root `.gitignore`,
build.tcl/make.tcl, bootsupport modules) have only partial refresh machinery. make.tcl's
`sync_layouts` step (punkcheck-tracked) syncs just `build.tcl` and `make.tcl`, and only into
the `custom/_project` layout base - the `vendor/punk/*` layouts have no refresh mechanism, so
payload drift is unmanaged.
## Approach
1. **Store VCS-config payloads inert.** Two candidate mechanisms; the choice is an
implementation decision to be recorded here when made:
- *Neutral rename*: payload stored as e.g. `_gitignore` or `gitignore.in`; layout
instantiation renames it to `.gitignore` in the generated project. Simple, no new
dependency; templates keep a literal copy (drift handled by the refresh step).
- *Fauxlink*: payload stored as `<nominal>#<encoded-target>.fauxlink` (see fauxlink module,
currently bootsupport 0.1.1) and resolved at generation. Inert for git by construction,
and the target can be the canonical root `.gitignore` itself - eliminating copy drift
rather than managing it. Requires generation-time resolution in punk::mix and possibly
promoting fauxlink to a first-class module dependency.
2. **Materialize at generation.** The punk::mix layout-instantiation path produces a real
`.gitignore` in the generated project regardless of mechanism, with content equal to the
canonical payload/target.
3. **Explicit template refresh in make.tcl.** Generalise the existing `sync_layouts` step into
a defined template-refresh covering the `vendor/punk/*` layout base as well as
`custom/_project`, and derived payloads beyond build.tcl/make.tcl (the gitignore payload,
and prospectively other mirrored content). punkcheck-tracked like the existing step, so
refreshes are recorded and skippable when sources are unchanged.
4. **Retire the stopgap.** With inert payloads, the per-file force-adds from fc1c474c become
ordinary tracked files (no ignore rule matches them); the accidental-ignore class is
structurally impossible rather than patched per file.
## Alternatives considered
- **Negation entries inside the nested template .gitignore files** (e.g. `!README.md`) -
rejected: pollutes the payload that generated projects receive, and cannot fix the
excluded-directory case (`bin/runtime/`) because git does not descend into excluded dirs to
evaluate negations.
- **Keep per-file `git add -f` only** - rejected as the durable answer: silent drop recurs for
every new file placed under a template's ignored paths; nothing structural changes.
- **`.git/info/exclude` or repo-local config tricks** - rejected: not versioned/portable to
other clones and contributors.
- **Do nothing on the git side (fossil sees the files)** - rejected: git is the day-to-day VCS;
a mirror having the truer view is a symptom, not a solution.
## Notes
- Affected today: `src/project_layouts/vendor/punk/{basic,minimal,project-0.1,sample-0.1}/.gitignore`.
The same inert-payload treatment should apply to any future live-config payloads in layouts
(e.g. `.gitattributes`); `.fossil-settings/` payloads in layouts are inert by nature (fossil
reads them only at a checkout root) and need no change.
- fauxlink background: target encoded in the filename (`+` for `/`, url-style escapes), no
filesystem support required, application-driven resolution - see
`src/bootsupport/modules/fauxlink-0.1.1.tm` doctools header.
- Verification tie-in: the dual-VCS sync checks in `.fossil-settings/AGENTS.md` double as a
regression detector for this class - after this goal, the "git-tracked invisible to fossil"
comparison should show no template-payload entries.

71
goals/G-013-raw-mode-default.md

@ -0,0 +1,71 @@
# G-013 Raw mode as the repl's default input mode
Status: proposed
Scope: src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/console-999999.0a1.0.tm
Acceptance: as in root GOALS.md index (canonical).
## Context
Line mode is today's default because on Windows it gives OS-provided line editing
and arrow-key history for free. Raw mode is the intended default, blocked by (1)
debug output emitted with cursor-move sequences, and (2) unfinished raw editor
features. Beyond display polish, flipping the default retires the last known
reader-cooperation hole on tcl 8.6: terminal queries fired while the line-mode
reader is armed at an idle prompt are hostage-prone by design (punk::console 0.7.1
made this fail fast rather than hang; raw default makes it work). It also removes
the line-mode type-ahead-loss window during terminal-query raw cycling
(#review, repl-999999.0a1.0.tm ~1677).
## Debug emission inventory (as of repl 0.2.2)
1. **add_chunk top-left frame** (repl ~1145-1173) - per-keystroke framed dump of the
overtype::renderline merge, painted at row 2 col 1. Currently ungated. This is
definitely a debug feature: it gets its own toggle, operable from within a
running repl, default off.
2. **Right-hand live editbuf view** (console_editbufview, repl ~2043, called ~2315) -
currently ungated. An important development aid for per-keystroke features
(e.g. command completion) and stays available, but behind its own separate gate.
Its startup default should ultimately come from stored configuration via
::punk::config (see G-014) - toml-backed, independently configurable per named
subshell - with off as the built-in fallback. Longer term, once the console
framework is more complete (G-001 backends), this live view could be streamed
off-console instead - e.g. over udp to a monitoring app, or to a tk text-widget
system acting as a pseudo terminal.
3. **console_debugview** (repl ~1976) - already gated (debug_repl > 0).
4. **Marked-line form** (debug_repl_emit, repl ~2566-2599, debug_repl > 100) - emits
prefixed lines before the command result via the debugreport pseudo-channel, no
cursor addressing. Retained as the baseline debug form for simple terminals
(vt52, other console systems we implement) where complex terminal interaction
is unavailable or unwanted.
## debugrepl activation review
Current mechanism: first word "debugrepl" in the submitted command is intercepted
before eval; bare/N/cancel manipulate a oneshot decrementing counter whose
thresholds (>0, >100) select display layers. Review whether first-word
interception is worth retaining or whether a proper Tcl command is better: a real
command can be restricted in subshells via interp hide (aligns with G-003/G-009
restriction mechanisms), and named toggles would replace the magic thresholds now
that gates are separate. Whatever the outcome, the marked-line output form itself
stays (see inventory item 4).
## Approach notes
- Cursor save/restore is a single slot, not a stack: the code already mixes
cursorsave_ and plain move_emitblock variants to avoid collisions (comment at
repl ~2031). Toggleable frames need one coherent cursor-bookkeeping policy.
- The raw chunk path calls punk::console::get_size per keystroke to position the
rhs frames (repl ~2296-2308, acknowledged 1+ms). Cache the size with explicit
invalidation; avoid any per-keystroke terminal query when no debug view is on.
- Raw editor stubs to replace: arrow-right/up and esc currently just rputs debug
notices (repl ~2477-2492); history navigation and cursor movement are the
parity items.
- Toggle startup defaults resolve through ::punk::config stored configuration when
available (G-014); built-in defaults apply when no configuration exists.
## Alternatives considered
- Keeping line mode as default and making idle-prompt queries cooperate with the
8.6 parked reader - rejected: the 8.6 console driver parks a cooked ReadConsole
that a raw flip cannot rescue (established 2026-07-05); cooperation is not
achievable there, only fail-fast.

61
goals/G-014-punk-config-toml.md

@ -0,0 +1,61 @@
# G-014 ::punk::config stored configuration: toml files with named-subshell scoping
Status: proposed
Scope: src/modules/punk/config-0.1.tm, src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-999999.0a1.0.tm, src/vendormodules/tomlish-*.tm (vendored; canonical source in the external tomlish project space)
Acceptance: as in root GOALS.md index (canonical).
## Context
punk::config 0.1 already establishes the config-dir convention (XDG_CONFIG_HOME
preferred, ~/.config/punk/shell fallback - see punk::config::dir) and declares
toml as the config file format, with a configset/config.toml startup-file notion
sketched but not implemented. Runtime configuration today is a mix of env-var
derived values and hardcoded defaults; there is no path for a user to declare a
setting in a file and have a feature pick it up, and no way to configure named
subshells independently.
The immediate consumer motivating completion is G-013: the raw-mode debug-view
toggles (notably the right-hand live editbuf view) need startup defaults that
come from stored configuration rather than hardcoded values, and a named subshell
should be able to carry its own defaults (a subshell dedicated to developing
per-keystroke features might default the editbuf view on, while the top-level
shell keeps it off).
## Precondition: tomlish punk::args documentation
All toml parsing/serialisation goes through the tomlish module - no ad-hoc toml
parsing in punk::config. tomlish is vendored (src/vendormodules/tomlish-1.1.8.tm
at time of writing, with a bootsupport copy) but its canonical source lives in a
separate project space under our control: documentation work happens upstream
there and lands here by re-vendoring, not by editing the vendored copies.
As of 1.1.8, punk::args coverage exists only on the cmdline app layer
(tomlish::app::decode_to_typedjson, tomlish::app::encode_from_typedjson,
tomlish::app::test, and the tomlish::cmdline definition). The library API procs
punk::config would consume (e.g. tomlish::from_toml, tomlish::to_dict,
tomlish::from_dict, tomlish::update_tomlish_from_dict) have no PUNKARGS argdoc
blocks. Those procs must gain punk::args documentation upstream and the updated
tomlish be re-vendored before punk::config implementation proceeds.
## Approach
- All toml reading/writing via the tomlish module (see precondition above).
- toml files under the punk::config::dir location, organised by configset.
- Resolution order: named-subshell override -> parent/default scope -> built-in
default. Absence of any config files must be a fully silent, working state
(beyond the existing missing-dir notice).
- Settings must be resolvable from the contexts that need them (repl thread and
code interp / codethread workers), consistent with the G-007
location-transparency model.
- Ties into G-009 themed subshell profiles: a named profile is a natural configset
consumer.
## Alternatives considered
- Write-back (persisting a toggle changed in a running repl to the toml configset
on explicit request, surviving restart) - deferred: kept out of acceptance to
stop this goal sprawling; a natural follow-on goal once read-side resolution is
proven.
- Reusing Tcl-native config formats (plain dict/script files) - rejected: toml is
already the declared format in punk::config 0.1 and is editable/diffable by
users without Tcl knowledge.

85
goals/G-015-script-subcommand-piped-stdin.md

@ -0,0 +1,85 @@
# G-015 Punk executable `script` subcommand: reliable non-interactive piped/script execution
Status: proposed
Scope: src/vfs/_config/punk_main.tcl, src/lib/app-punkshell/punkshell.tcl (script path or a leaner dedicated app package)
Acceptance: as in root GOALS.md index (canonical).
## Context
Agents (and exec-style callers generally) currently have no reliable way to run a
one-shot command against a punk executable. The working pattern is to pipe repl
input into the default shell path, e.g. (powershell):
'dev projects.work *tomlish*;exit' | punk902z
This is fragile in two ways:
1. The trailing `exit` is load-bearing: without it, stdin EOF triggers the
app-punkshell eof handling, whose default heuristic policy attempts to reopen
the console (`CONIN$` / `/dev/tty`) and drop into an interactive repl. When a
console exists but no human is watching (typical agent/CI harness), that is a
permanent hang. `PUNK_PIPE_EOF=exit` mitigates, but callers must know to set it.
2. If any command before the `exit` errors, the repl keeps consuming input and
the `exit` line may never run as intended - the session can still land
interactive, and the process exit code does not reflect the failure.
Meanwhile the `script` subcommand in the punk_main.tcl dispatch
(src/vfs/_config/punk_main.tcl ~1163) is an incomplete stub: it prints stderr
diagnostics ("main.tcl launching script with args: ...") and sets
::tcl_interactive, but never actually executes anything. Note also the dispatch
default (~1090): any first argument that is not a recognised mode/subcommand is
reclassified as `script`, so completing this path also completes the bare
`<punkexe> <somefile>` form.
The `shell` subcommand path (app-punkshell) wraps the session in shellfilter
channel stacks (`punkshellout`/`punkshellerr` with file/syslog logging targets,
plus ansiwrap on stderr in places). Those transforms exist for interactive-shell
ergonomics and logging; for a one-shot script call they add overhead, alter
output, and risk stderr chatter - which matters because Tcl `exec` treats any
stderr output as command failure by default (see the comment block in
punk_main.tcl's `punk` subcommand case).
## Approach
- `<punkexe> script <file> [<args>...]` - execute the file with conventional
::argv0/::argv semantics, exit with its status.
- `... | <punkexe> script` - read the whole of stdin as the script when no
scriptname is supplied and stdin is piped/redirected; terminate at EOF, never
reopening a console. The scriptname form must still work with piped stdin
present (the script may itself want to read stdin) - "with or without a
trailing scriptname".
- No shellfilter stacks/transforms on the script path; plain channels.
- No launcher output of its own on stdout or stderr (remove the stub
diagnostics); the script's own output is the only output.
- Errors: report on stderr, exit nonzero. Success: exit 0 (or the script's own
explicit exit code). No interactive fallback on this subcommand under any
eof/error condition - PUNK_PIPE_EOF and console availability are irrelevant
here by design.
- The script interp loads the basic punk modules and aliases that are available
by default in an interactive punk shell, so commands like `dev projects.work`
work in a piped one-liner with no `package require` boilerplate. This is
deliberately not the leanest environment for basic scripts - fast/bare script
execution is the future province of an expanded `tclsh` subcommand, out of
scope here. The exact recommended one-liner gets documented under G-017.
## Alternatives considered
- Hardening the shell/repl pipe path instead (PUNK_PIPE_EOF, error-aborts-input)
- insufficient alone: the shell path's channel transforms and repl semantics
are wrong for exec-style callers even when eof/error policy is fixed; a
dedicated clean path is simpler than making the interactive path dual-purpose.
- Requiring agents to set PUNK_PIPE_EOF=exit and keep the trailing `exit` -
rejected as the durable answer: it leaves exit codes meaningless on mid-script
errors and depends on every caller knowing the incantation.
- A bare interp (no punk modules/aliases preloaded) for the script path -
rejected for this subcommand: the point is that shell-default commands
(`dev projects.work` etc.) work as piped one-liners; lean bare-script
execution belongs to a later expansion of the `tclsh` subcommand.
## Notes
- The eof policy machinery (PUNK_PIPE_EOF exit/interactive/heuristic) in
app-punkshell (~punkshell.tcl 466-507) stays as-is for the `shell` subcommand;
this goal does not change interactive-shell behaviour.
- Related later goal: G-016 extends what the motivating `projects.work` call can
find; G-017 documents the agent-facing invocation pattern once this lands.

58
goals/G-016-projects-work-git-discovery.md

@ -0,0 +1,58 @@
# G-016 `projects.work` discovers git-based projects alongside fossil
Status: proposed
Scope: src/modules/punk/mix/commandset/project-999999.0a1.0.tm, src/modules/punk/repo-999999.0a1.0.tm
Acceptance: as in root GOALS.md index (canonical).
## Context
`dev projects.work <glob>` (punk::mix::commandset::project::collection::work)
answers "where are my projects checked out" by opening the central fossil
config-db, globbing repository database filenames, and listing the known
checkout directories per repo - with optional per-checkout file-state detail.
This makes it the natural mechanism for agents to locate sibling projects
(e.g. the tomlish project space referenced by G-014) instead of recursively
scanning the filesystem.
The gap: it is fossil-only. Git-based projects are invisible to it, and git has
no equivalent of fossil's central config-db - there is no built-in registry of
clones on a machine. So git discovery needs a defined enumeration source of its
own before the listing can be extended.
## Approach
Enumeration-source candidates (implementation decision, to be recorded here when
made):
- **Configured search roots**: a punk::config setting (natural G-014 consumer)
listing parent directories to scan one or two levels deep for `.git` dirs.
Bounded scan, no registry maintenance, but discovery limited to declared roots.
- **Punk-maintained registry**: record project paths when punk tooling
creates/opens them (and offer a scan-once command to seed it). Fast lookups,
works for arbitrary locations, but can go stale.
- Hybrid: registry seeded/refreshed by an explicit scan of configured roots.
Result-shape considerations:
- Each row identifies its VCS. Fossil rows keep their current columns
(repo db filename, project name/code, checkout dirs, dup-set annotations);
what the git analogue of "project name" is (dir name, remote URL tail,
configured name) is part of the design work.
- A dual git+fossil workdir (like the shellspy repo itself) is one project row
with both VCSs indicated, not two rows.
- Fossil supports multiple checkouts per repo db; git worktrees are the
analogous multi-workdir case and should at least not break the listing.
## Alternatives considered
- Filesystem-wide scanning at query time - rejected: unbounded cost and exactly
the behaviour this mechanism is meant to replace for agents.
- Relying on external tools' state (e.g. IDE/zoxide/gh caches) - rejected:
non-portable, not present on all machines, opaque formats.
## Notes
- Depends on nothing, but its value to agents is realised through G-015
(reliable piped invocation) and G-017 (agent guidance documenting the call).
- The `-cd` / `-detail` options and case-insensitive glob behaviour of the
existing command are contracts to preserve.

56
goals/G-018-zig-plain-tclsh-kits.md

@ -0,0 +1,56 @@
# G-018 Zig-built plain tclsh kits: self-contained zip-based executables without punk infrastructure
Status: proposed
Scope: build.zig / build.zig.zon (per G-005), src/runtime/, src/make.tcl integration
Acceptance: as in root GOALS.md index (canonical).
## Context
G-005 establishes zig-based build infrastructure to build binary dependencies
(including Tcl9) from source. This goal extends that infrastructure's output
targets: beyond building the punkshell product's own runtimes, developers should
be able to produce plain, self-contained, zip-based tclsh executables with no
punk-specific infrastructure at all - no punk boot layer (punk_main.tcl
dispatch), no punk modules, no punkshell apps. A general-purpose single-file
tclsh, useful independently of punkshell.
This defines one end of a deliberate spectrum of buildable executables:
- plain tclsh kit (this goal) - stock Tcl only
- trimmed punk executable (G-019) - punk infrastructure plus only the modules a
dependency scan proves needed
- 'batteries included' punk executable - the current full-kit approach
## Approach
- Zip-based (zipfs) kit assembly as the packaging mechanism, consistent with
Tcl 9's built-in zipfs support.
- **Tcl 8.6 phasing (decided 2026-07-06)**: the 8.6 kit mechanism is more
complex to support (no built-in zipfs) and is wanted in the end, but must not
block other work. Interim step: support plain (non-kit) tcl 8.6 builds from
the zig infrastructure, and implement 8.6 kits when practical. This goal's
acceptance is satisfiable by a Tcl 9 kit alone; 8.6 kit support is a
follow-on within the same goal's spirit, not a gate.
- The plain kit is a natural first smoke target for G-005's Tcl-from-source
build: it exercises the compiled runtime with minimal packaging logic before
the more elaborate punk kit assembly.
- `src/make.tcl` integration so the target is invocable through the normal
project build entrypoint as well as directly via zig.
## Alternatives considered
- Relying on third-party tclkit/kitcreator builds for plain kits - rejected as
the durable answer: the point of G-005 is developer-reproducible builds from
source in this project's toolchain, and external kit builders are exactly the
committed-binary/procurement dependency G-004..G-006 are removing.
## Notes
- Depends on G-005 (zig builds Tcl from source); complements G-004 (no committed
binaries) by making plain runtimes reproducible on demand.
- 'Self-contained' means: runs conventional tclsh invocations on a machine with
no Tcl installation - stock Tcl library scripts ride inside the kit.
- Wish executables are not a build target at any point on the spectrum: kits
that want Tk ship it as a loadable package (as the punk Windows kit already
does - `package require Tk` from the tclsh-style executable, console
retained). Applies to plain kits here and punk kits alike.

67
goals/G-019-dependency-scan-module-trimming.md

@ -0,0 +1,67 @@
# G-019 Dependency-scan-driven module trimming for punk-based executables
Status: proposed
Scope: src/make.tcl, src/modules/punk/lib-999999.0a1.0.tm (tclparser use), src/vfs/ (kit assembly), scanning module (new or existing punk module - to be determined)
Acceptance: as in root GOALS.md index (canonical).
## Context
Punk executables are currently 'batteries included' - the kit assembly ships the
full module snapshot regardless of what a given executable's app actually uses.
For executables built around a specific app or entrypoint, a package-dependency
scan could determine the module closure genuinely required and let the build
ship only that, giving smaller artifacts. Batteries-included builds remain a
supported alternative (the middle and far points of the spectrum described in
G-018's context section).
The candidate scanning basis is static parsing of Tcl source via the tclparser
`parse` API (tclpro-descended; https://wiki.tcl-lang.org/page/tclparser). Its
current availability shapes the work:
- The real implementation is the **c-only tclparser library**
(`package require parser`); punk::lib uses it opportunistically.
- punk::lib carries a same-API pure-Tcl fallback, `punk::lib::tclparser_tcl`
(src/modules/punk/lib-999999.0a1.0.tm ~3322), which is an unimplemented stub
that errors advising installation of the C library.
- punk::args::moduledoc::parser documents the parse API.
Using the C library inside build tooling makes the build depend on a native
binary - intersecting the G-004/G-005/G-006 binary-provenance story (zig-built
or consent-downloaded, never committed). Completing the pure-Tcl fallback would
remove that coupling at some accuracy/performance cost. The choice (or ordering
- e.g. C library first, fallback later) is an implementation decision to record
here.
## Approach
- Static scan from the executable's entrypoint/app: walk `package require`
(and `tcl::tm` / source-time load constructs) through the reachable sources,
resolving the transitive closure against the project's module trees.
- **Dynamic requires are the known limit**: computed package names, plugin-style
loading, and `package require` inside rarely-hit code paths cannot be proven
statically. A declaration mechanism for extras (per-executable manifest of
packages to include beyond the scan result) is part of the goal's acceptance,
not an afterthought.
- Scan output should be a reviewable artifact (the closure list), feeding kit
assembly rather than silently filtering it - so a wrong exclusion is
diagnosable from the build record.
- Verification of a trimmed kit is behavioural (starts, passes its app's smoke
check) plus structural (module listing strictly a subset of the
batteries-included equivalent).
## Alternatives considered
- Runtime tracing (run the app, record `package require` calls) instead of
static parsing - rejected as primary: coverage-dependent (only exercised
paths are seen), though it could later complement the static scan for
validating the declared-extras list.
- Naive regex scan for `package require` lines - rejected: misses computed
names it can't even flag, false-positives in strings/comments; the tclparser
parse API exists precisely to do this properly.
## Notes
- Interacts with G-005/G-006 (how the c-only tclparser binary is provisioned if
chosen) and G-018 (the spectrum of executable flavours).
- punk::lib sites already noting tclparser use/fallback: tclword_to_scriptlist
and related parsing helpers (~lib 3322-3580).

86
goals/G-020-screencap-input-module.md

@ -0,0 +1,86 @@
# G-020 Screen capture and input injection module with per-platform backends (Windows first)
Status: proposed
Scope: src/modules/punk/ (new module - name TBD), src/vfs/punk9win.vfs/lib_tcl9/ (existing treectrl/Img/twapi payloads); scriptlib/aloupe.tcl stays untouched as a standalone app
Acceptance: as in root GOALS.md index (canonical).
## Context
Punk already runs Tk from tclsh-style kits with the console retained (Tk is a
loadable package in the kit; the repl event loop services it - no wish
executable involved, and wish binaries are not a build target). The standalone
`scriptlib/aloupe.tcl` (aloupe 1.8.2, MIT) demonstrates the capture primitive:
treectrl's `loupe` command copies an arbitrary screen region into a Tk photo
(aloupe.tcl ~454), with Img for extra file formats. The Windows kit already
ships every needed binary: `src/vfs/punk9win.vfs/lib_tcl9/` has tk9.0,
treectrl 2.5.2 (tcl9treectrl252.dll), Img 2.0.1/2.1.0, twapi 5.0.2, and
cffi 2.0.3.
This goal turns that demonstration into a scriptable module: capture plus
mouse/keyboard injection plus window location, so scripts (and, via G-021,
agents) can drive and observe GUI state. aloupe.tcl remains a standalone app;
the module is a separate development, not a refactor of it.
## Platform assessment (investigated 2026-07-06)
- **Windows (initial primary target)** - no show-stoppers, all deps already in
the kit. Capture: treectrl loupe (GDI). Input: twapi `send_input`
(SendInput). Window locate: twapi enumeration/geometry/foreground. Gotchas to
handle/document: desktop session must be unlocked and interactive (locked
workstation / disconnected RDP -> black captures, failed injection);
per-monitor DPI scaling affects coordinate mapping; UIPI blocks injecting
into elevated windows from a non-elevated process; multi-monitor negative
coordinates.
- **Linux (X11)** - supported: treectrl loupe X11 impl (XGetImage); injection
via XTest (xdotool external tool, or cffi binding to libXtst); locate via
xdotool/wmctrl. Xvfb gives a fully headless mode - the preferred arrangement
for automated visual verification.
- **Wayland-native - OUT OF SCOPE (decided 2026-07-06)**: XGetImage cannot see
native Wayland surfaces and XTest cannot inject into them by design; the
escape hatches (xdg-desktop-portal Screenshot/ScreenCast D-Bus APIs, ydotool
via uinput) are permission-heavy with no ready Tcl bindings. Revisit via
portals if/when needed; X11/Xvfb is the supported Linux mode.
- **FreeBSD** - same X11 story; tktreectrl/tkimg/xdotool in ports; twapi N/A.
- **macOS** - supportable, permission-gated: TCC requires Screen Recording
(capture) and Accessibility (injection) grants to the hosting process -
interactive one-time grants, awkward for unattended CI. treectrl loupe aqua
support is unverified and non-blocking: the built-in `screencapture` CLI
(region/window-id to file) is the natural capture backend; injection options
are osascript/System Events, cliclick (third-party), or cffi -> CGEventPost.
`src/vfs/macosx-arm/` already exists in the tree.
## Approach
- Three backend roles behind one API: **capture**, **input**, **window-locate**.
Per-platform backend selection with capability introspection - callers ask
what is supported rather than probing by failure. Echoes the G-001 pluggable
pattern.
- Windows backend first and complete (acceptance gates on it). X11 and macOS
backends are in-scope follow-ons behind the same contract; their absence on a
platform must produce a clean capability-based refusal.
- Output normalization: capture lands in a Tk photo; PNG via Tk's core photo
support (Img optional for other formats). File save and in-memory (base64)
both supported - the G-021 consumer needs both.
- cffi (bundled) is the escape hatch for gaps without new C extensions - e.g.
Windows PrintWindow for capturing occluded windows (region-based loupe cannot),
libXtst on X11 if avoiding the xdotool external-tool dependency, CGEventPost
on macOS.
- Binary deps (treectrl, Img, twapi, cffi) follow the G-004/G-005/G-006
provisioning story on each platform; nothing new is committed to the repo.
## Alternatives considered
- Refactoring aloupe.tcl into the module - rejected: it is a vendored-style
standalone app under user-only scriptlib/; keeping it untouched preserves a
known-working reference implementation.
- Pure-twapi capture on Windows (skip treectrl) - not pursued initially: twapi
has no ready BitBlt/screen-grab wrapper; treectrl loupe is already shipped
and proven. cffi/PrintWindow may supplement later for occluded windows.
- Wayland-native support via portals/ydotool - out of scope (see platform
assessment); recorded for possible future revisit.
## Notes
- Related: G-018 (kit composition; Tk as loadable package, no wish binaries),
G-019 (a trimmed capture-capable executable is a plausible scan target),
G-021 (agent-facing surface over this module), G-001 (backend plugin pattern).

55
goals/G-021-agent-visual-verification.md

@ -0,0 +1,55 @@
# G-021 Agent-drivable visual/UI verification via piped snapshot and interaction calls
Status: proposed
Scope: src/modules/punk/ (G-020 module's agent-facing surface), AGENTS.md guidance (post G-015 pattern), src/tests/ (visual-verification test hooks)
Acceptance: as in root GOALS.md index (canonical).
## Context
Some behaviours can only be verified visually (rendering, theming, layout,
ANSI-vs-Tk display parity) or by driving real input (mouse/keyboard-dependent
flows). Today an agent in a session has no sanctioned way to see or drive GUI
state. With G-020 providing capture/input/window-location as a module and
G-015 providing reliable piped one-shot script calls, the combination lets a
tool-calling agent: determine the applicable UI window, snapshot it, analyze
the image (agents are multimodal), and drive interactions - all from ordinary
tool calls during a session.
## Approach
- Thin agent-facing surface over the G-020 module, exercised through the G-015
`script` subcommand: each operation is a single piped call with
machine-parseable output and G-015 exit-code semantics (nonzero + stderr on
failure, never an interactive shell).
- Operations: window list/match by pattern; snapshot a located window or region
to a caller-specified PNG path; optional base64-on-stdout emission for direct
image ingestion by the calling agent; scripted interaction sequences (focus,
click at offset, key/type, snapshot) composable in one piped script.
- Windows first, per G-020. Capability introspection flows through, so an agent
on an unsupported platform gets a clean "not supported here" rather than a
hang or crash.
- Documentation for agents lands alongside the G-017 guidance (the same "use
the punk mechanism, not ad-hoc scanning/screenshotting" pattern): exact
invocations, output shapes, and the session prerequisites (unlocked desktop,
DPI notes) from G-020's platform assessment.
- At least one real verification consumer in src/tests/ or a runbook (e.g. a
Tk app smoke test asserting on a snapshot) proves the loop end-to-end.
## Alternatives considered
- OS-level screenshot tooling driven directly by agents (PowerShell/GDI
one-liners, screencapture, import/scrot) - rejected as the sanctioned path:
per-platform incantations with no window-location or input story, no
capability model, and nothing reusable by punk scripts; the punk module route
gives one contract everywhere punk runs.
- Building agent tooling (MCP server or similar) directly into punk now -
deferred: piped G-015 calls already fit existing agent Bash/exec tools; a
richer integration can layer later without changing the module.
## Notes
- Depends on G-020 (mechanism) and G-015 (reliable piped calls); guidance
placement follows G-017.
- Image handoff sizing: agents ingest images with dimension/size limits;
snapshot operations should support downscale/crop options eventually -
acceptance keeps to save + base64, with sizing noted as a natural follow-on.

75
goals/G-022-fossil-rename-punkshell.md

@ -0,0 +1,75 @@
# G-022 Scriptable safe fossil move/rename in `dev repo`; rename this project's fossil repo to punkshell
Status: proposed
Scope: src/modules/punk/mix/commandset/repo-999999.0a1.0.tm, src/modules/punk/repo-999999.0a1.0.tm, src/tests/modules/punk/mix/testsuites/repo/
Acceptance: as in root GOALS.md index (canonical).
## Context
This project's fossil repository is recorded as project-name 'shellspy'
(C:/Users/sleek/.fossils/shellspy.fossil at the time of writing) but the
desired project name is punkshell ('shellspy' names only the experimental
launch subcommand). Rather than having an agent perform ad-hoc repo surgery,
the rename should go through the `dev repo` system - which makes the
prerequisite the reliability of agent-driven `script` calls (G-015).
Current capability (examined 2026-07-06, characterized by
src/tests/modules/punk/mix/testsuites/repo/fossilmove.test):
- `punk::mix::commandset::repo::fossil-move-repository` exists but is fully
interactive (punk::repo::askuser prompts throughout) - unusable from a
piped script call.
- Its move primitive is `file copy` + `fossil test-move-repository` per
checkout. Verified in a FOSSIL_HOME-isolated sandbox, this sequence:
- correctly repoints the checkout's repository link (the part that works);
- leaves the old .fossil file on disk (copy, never delete);
- leaves a phantom `repo:` entry for the old path in the central fossil
config-db (`fossil all ls` lists both old and new paths);
- leaves a stale `ckout:` back-reference in the old repo db.
- Nothing renames the fossil project-name: moving/renaming the db file leaves
`project-name` in the repo db config table untouched, and fossil has no
plain CLI setter for it (web UI Admin/Configuration, or a config-table
update e.g. via `fossil sql`).
Phantom central-db entries matter beyond hygiene: `dev projects.work`
(G-016/G-017's agent-facing project discovery) reads the central config-db, so
phantoms surface directly in agent search results.
## Approach
- Extend the commandset with a non-interactive mode (explicit flags for
target path/name, project-name change, old-db disposition) alongside the
existing interactive flow; punk::args-documented per module conventions.
- Safe-move sequence: copy to target, repoint every registered checkout
(`fossil test-move-repository` per checkout, or direct checkout-db vvar
update), remove the old central-db `repo:` entry, clear/transfer stale
`ckout:` records, then remove or archive the old db file - ordered so a
failure mid-way never leaves checkouts pointing at a missing db.
- Project-name rename: update `project-name` in the repo db config table
(project-code must never change - it is the repo's identity); surface both
names in the operation's report.
- The fossilmove.test GAP tests (3.2, 3.3, 3.4, 4.1) pin today's behaviour;
flipping them to assert clean behaviour is part of this goal's verification.
- The final application to this repo runs through a G-015 piped `script`
call, as the proving use of agent-driven `dev repo` operations.
## Alternatives considered
- Ad-hoc agent-performed rename (fossil sql + file rename + manual central-db
cleanup by hand) - rejected: the point is that repo surgery goes through
audited `dev repo` tooling agents can reuse, not one-off incantations.
- Renaming only the .fossil file and leaving project-name as shellspy -
rejected: the project-name is the visible identity in `fossil info`,
timelines and project listings; it is the thing actually wrong.
## Notes
- Dependent on G-015 (reliable piped script calls) for the application step;
the capability work itself has no dependency and can proceed earlier.
- The working-tree directory name (C:/repo/jn/shellspy) is out of scope -
fossil does not require checkout dirs to match the project name.
- Other checkouts of shellspy.fossil (e.g. a shellspy_copy working tree, if
open) must be enumerated and repointed by the same run.
- Test isolation pattern for anything touching fossil state: set FOSSIL_HOME
to a temp dir and verify `fossil info` reports config-db under it before
mutating (see fossilmove.test fixture guard).

67
goals/G-023-version-named-binaries.md

@ -0,0 +1,67 @@
# G-023 Version-named punk binaries per Tcl generation (versioned / dev / release-gated plain names)
Status: proposed
Scope: src/make.tcl, src/runtime/ (mapping config - see G-024), bin/ (build outputs)
Acceptance: as in root GOALS.md index (canonical).
## Context
The project version is now tracked and change-driven-bumped in punkproject.toml
(root AGENTS.md "Project Versioning"), so builds can be named after it. Today's
build outputs are fixed names declared line-by-line in src/runtime/mapvfs.config
(punksys, punk902z, ...) that encode the runtime generation informally and say
nothing about the project version they contain - which build a given exe
represents is unknowable without launching it (G-025 addresses the launching
side; this goal addresses the naming side).
Naming scheme:
- `punk8-<major>-<minor>-<patch>.exe` / `punk9-<major>-<minor>-<patch>.exe` -
produced as the version advances; a rebuild at an unchanged version refreshes
that version's binaries in place.
- `punk8-dev.exe` / `punk9-dev.exe` - always the latest build, refreshed every
build.
- `punk8.exe` / `punk9.exe` - created initially, then replaced only by an
explicit release step when an actual release is tagged. A normal build never
touches them.
The `8`/`9` generation split matches the existing dual-generation verification
practice (bin/AGENTS.md: a Tcl 8.6 punk shell and a current Tcl 9 punk shell).
## Approach
- Names derive from punkproject.toml at build time - no hand-maintained
per-version config entries. The scheme is declared once, succinctly, in the
G-024 toml mapping; the expansion to concrete output names happens in
make.tcl.
- The release step that updates the plain punk8.exe/punk9.exe names should be
an explicit make.tcl action tied to release tagging, not a side effect of a
normal build. Its precise trigger (fossil/git tag presence, or a manual
subcommand) is an implementation decision to record here.
- Existing specifically-named outputs (punksys, punk902z, ...) keep working via
their explicit mappings (G-024 preserves them); the versioned scheme is
additive.
## Space and archival
These binaries are large. Accumulating one pair per project version is
accepted for now. Out of scope but recorded for later: a trigger mechanism for
archival/deletion (e.g. keep-latest-N per generation, prune non-release
versions older than a threshold, or move to an artifact store per G-006's
binary-artifacts repository). Revisit when the accumulation actually bites.
## Alternatives considered
- Encoding the version only inside the kit (G-025) and keeping fixed exe names
- rejected as insufficient alone: side-by-side versioned binaries are the
point (running/comparing multiple versions without rebuilds).
- Symlinks/hardlinks for -dev and plain names instead of copies - deferred to
implementation: Windows link semantics for running executables are fiddly;
copies are acceptable at current sizes.
## Notes
- Related: G-024 (scheme declared in toml mapping), G-025 (exe self-reports its
version/provenance - the complement of name-encoded versioning once files
are renamed/copied), G-006 (future artifact-store home for archived
versions), G-018/G-019 (other points on the executable-flavour spectrum).

65
goals/G-024-mapvfs-toml.md

@ -0,0 +1,65 @@
# G-024 mapvfs.config converted to toml (tomlish-parsed) with succinct scheme declarations
Status: proposed
Scope: src/runtime/mapvfs.config (replaced/deprecated), src/make.tcl (parsing), src/bootsupport/modules/tomlish-*.tm (parser dependency)
Acceptance: as in root GOALS.md index (canonical).
## Context
src/runtime/mapvfs.config maps runtime binaries and .vfs folders to named
executables in a custom line format: one line per runtime, being the runtime
filename followed by kit-configuration lists of 1-3 elements
({vfsfolder ?outputname? ?kittype?}, kittype in kit|zip|zipcat, `-` as runtime
for runtime-less .kit builds, no-entry runtimes matching same-named .vfs
folders implicitly). make.tcl parses it inline (~2795-2845). The format works
but is idiosyncratic (comment/blank handling, positional defaults), is parsed
ad hoc, and has no way to express a *scheme* - every output is one literal
mapping, which conflicts with G-023 where output names derive from the project
version at build time.
tomlish is the project's toml parser (vendored at src/vendormodules and
src/bootsupport/modules - the bootsupport copy matters since make.tcl is build
tooling), so the replacement format is toml parsed via tomlish, consistent
with the G-014 direction of toml + tomlish for declared configuration.
## Approach
- A mapvfs toml file in src/runtime/ carrying two kinds of declarations:
- **Explicit mappings** - the current capability, one entry per output:
runtime, vfs folder, output name (defaulting from the vfs name), kit type.
Everything active in mapvfs.config today must be expressible.
- **Scheme declarations** - generative entries expanded by make.tcl, first
consumer being the G-023 versioned scheme: one entry per Tcl generation
declaring runtime + vfs + scheme=versioned (or similar) that expands to
punkN-<M>-<m>-<p>.exe, punkN-dev.exe and the release-gated punkN.exe
without enumerating versions.
- Validation with entry-level error reporting: a malformed or unresolvable
entry (missing runtime file, missing vfs folder, unknown kit type/scheme)
fails the build naming the offending entry - matching the current warnings
but structured.
- Legacy handling is a decision to record here: either full migration (convert
the file, delete the old parser) or a deprecation window (toml wins when
present, .config honoured with a deprecation warning). Full migration is
preferred - the file is small and repo-internal - unless project_layouts
templates complicate it (layout copies of make.tcl/mapvfs.config sync via
G-012 territory; note in completion report, do not hand-sync layouts).
## Alternatives considered
- Keeping the custom line format and extending it with scheme syntax -
rejected: doubles down on an ad-hoc parser exactly where the project is
standardising on toml + tomlish.
- Tcl-script config (source-able dict) - rejected for the same reason as in
G-014: toml is the declared configuration format, editable without Tcl
knowledge.
## Notes
- The commented-out historical entries in mapvfs.config (broken-runtime notes,
test cases) are documentation of past pitfalls - carry the useful ones into
toml comments or this file rather than losing them.
- Related: G-023 (first scheme consumer), G-014 (toml+tomlish direction and
the tomlish punk::args-documentation precondition - that precondition gates
punk::config consumption; build-tooling use here can proceed with tomlish
as-is), G-012 (layout template refresh, if layout copies of runtime config
are affected).

82
goals/G-025-exe-selfreport.md

@ -0,0 +1,82 @@
# G-025 Punk executables self-report project version and build provenance
Status: proposed
Scope: src/vfs/_config/punk_main.tcl (subcommand dispatch), src/make.tcl (stamping build info into the vfs), src/vfs/ (stamp payload location), src/modules/punk/ (in-shell command - the single implementation)
Acceptance: as in root GOALS.md index (canonical).
## Context
With G-023 producing version-named binaries, an executable's name encodes its
version - but names are mutable (copies, renames, the -dev and release-gated
plain names) and say nothing about the runtime binary or vfs folder the build
consumed. The executable should answer for itself from embedded data: a
version-report subcommand at the launch layer, and the same-named command in
the punk module for scripts already running inside a punk shell.
The in-shell command is not merely an optimization over exec'ing out. In a
tclsh-hosted punk shell (`tclsh src/make.tcl shell` loads punk::repl directly
into the host tclsh - make.tcl ~1913) there is no punk executable to exec:
`info nameofexecutable` is the external tclsh, and invoking it with the
subcommand would be meaningless. The in-process command is the only correct
path there - hence the module command is the single implementation and the
exe subcommand is a thin dispatch wrapper over it (which is also what
guarantees the two surfaces report identically).
## Report semantics: three distinct notions
Only in a plainly-launched stamped kit do these coincide; the report must keep
them distinct and never fabricate:
1. **Build stamp** - project version, runtime binary name, vfs folder name,
fixed at kit assembly by make.tcl. Absent in unstamped contexts.
2. **Live runtime facts** - actual `info nameofexecutable`, patchlevel.
Always available.
3. **Source-tree project version** - punkproject.toml, available when running
from a source tree regardless of stamping.
Edge cases driving the acceptance clauses:
- `tclsh src/make.tcl shell`: no stamp. Stamp fields reported explicitly
absent; live facts show the real host tclsh; the source-tree version may be
reported but flagged as live-from-punkproject.toml, not as a stamp.
- Kit launched in `src` mode (`punk902z src`): a stamp exists, but the running
code is working-tree dev modules whose punkproject.toml may be ahead of the
stamped version. Reporting only the stamp misstates what is running; only
the live version misstates provenance - report both, distinctly labelled,
when they differ.
- Plain tclsh with installed punk modules and no source tree: no stamp, no
punkproject.toml - live facts only.
- Renamed/copied executables: the report comes from the stamp, so it stays
correct whatever the file is called.
- Pre-stamping kits: the subcommand/command fails gracefully with a clear
message.
## Approach notes
- The command must work from the code interp (where scripts run), not just the
repl thread - trivial for static stamp data, but stated so the G-007
location-transparency expectation is met.
- The subcommand output must be machine-parseable, exit 0, and emit nothing
else on stdout/stderr - usable through G-015 piped calls and by exec-style
callers.
- Naming (e.g. `buildinfo`) is an implementation decision: the in-shell
command name must match the subcommand name and not collide with existing
repl commands.
- Stamp location within the vfs payload is an implementation decision to
record here (candidates: a dedicated stamp file under the vfs _config, or
fields merged into an existing boot config file).
## Alternatives considered
- In-shell command exec'ing its own executable with the subcommand - rejected:
wrong in tclsh-hosted shells (no punk executable to call), needless process
spawn elsewhere, and two code paths that can drift.
- Reporting only the stamped version and ignoring live/source-tree context -
rejected: actively misleading in `src`-mode sessions, which are the primary
working-tree verification workflow (bin/AGENTS.md).
## Notes
- Related: G-023 (version-named binaries this complements), G-024 (build
mapping that will drive stamping inputs), G-015 (piped invocation of the
subcommand), G-014 (punkproject.toml reading may share tomlish plumbing).

76
goals/G-026-vendor-provenance-policy.md

@ -0,0 +1,76 @@
# G-026 Enforceable clean-checkout provenance policy for vendor and bootsupport pulls
Status: proposed
Scope: src/make.tcl (vendorupdate and bootsupport steps), src/vendormodules/include_modules.config, src/bootsupport/modules*/include_modules.config
Acceptance: as in root GOALS.md index (canonical).
## Context
`make.tcl vendorupdate` pulls the latest release-stamped module files from each
source project's built `modules/` folder (paths declared in
include_modules.config). Nothing verified the source project's checkout state,
so artifacts built from uncommitted source could be vendored with no committed
provenance anywhere. This actually happened: tomlish 1.1.7/1.1.8 were vendored
into punkshell from a tomlish working tree whose fossil history ended at 1.1.6
- the released content existed only as uncommitted edits (recorded and
remedied 2026-07-06, tomlish checkin 37b71e82).
A warn-only check was added in project 0.2.5 (fix-now/goal-later review,
2026-07-06): `vendor_source_dirty_warnings` in make.tcl walks up from each
vendor source path to the nearest fossil and/or git root, runs
`fossil changes` / `git status --porcelain`, and warns once per VCS root when
dirty. The first real run flagged 8 dirty source projects, confirming the
problem is endemic, and equally that **abort-by-default would have made
vendorupdate unusable on this machine today** - which is why enforcement is a
goal (requiring cleanup and a policy/override design) rather than part of the
quick fix.
## Approach
- Promote the warning to a policy: refuse to pull from dirty source projects
unless an explicit override is given (flag such as
-allow-dirty-sources, or per-project allowance); warn-only remains
selectable as a configured mode. Where the mode is configured (command flag
vs punk::config once G-014 lands) is an implementation decision to record.
- Share one check implementation between vendor_localupdate and
bootsupport_localupdate (make.tcl already carries a
"todo - sync alg with bootsupport_localupdate!" note; do not grow a second
divergent copy).
- Per-root reporting and unversioned-location silence are established
behaviour from the 0.2.5 check - keep them.
## The residual staleness gap
A clean checkout is necessary but not sufficient: the built `modules/` files
are produced from the working tree at build time, so they can predate the
latest commits (stale) or - the reverse of the tomlish case - have been built
from dirt that was subsequently committed. Candidate mechanisms, decision to
be recorded here:
- Rebuild-on-demand: vendorupdate triggers the source project's module build
before pulling (correct, but couples projects' build systems).
- Provenance stamp: source projects stamp checkout id (fossil checkin /
git hash) into built modules at build time; vendorupdate compares stamp to
the source project's current checkout id (ties into the G-025 stamping
machinery pattern).
- Accepted risk: document that clean-checkout checking is the enforced bar and
staleness remains a manual concern.
## Alternatives considered
- Abort-by-default immediately (no goal, just flip the fix) - rejected for
now: the first run showed 8 dirty source projects; enforcement without a
cleanup pass and an override design would block routine work.
- Checking only the specific pulled files' dirtiness rather than the whole
source checkout - rejected: the built module is generated from many source
files; per-file dirt attribution against a build product is not tractable.
## Notes
- include_modules.config files carry their own "#todo - change to
include_modules.toml" - a natural companion change under the G-024
toml-conversion pattern, but deliberately not part of this goal's
acceptance.
- Related: G-014 (tomlish provenance incident motivated this), G-024 (toml
config direction), G-025 (build stamping machinery a provenance stamp could
ride on).

82
goals/G-027-derived-project-pull-updates.md

@ -0,0 +1,82 @@
# G-027 Pull-based infrastructure updates for punkshell-derived projects
Status: proposed
Scope: src/modules/punk/mix/commandset/project-999999.0a1.0.tm (project.new push path), src/modules/punkcheck-999999.0a1.0.tm (install provenance records), src/make.tcl (derived-project pull entrypoint)
Acceptance: as in root GOALS.md index (canonical).
## Context
Projects initialised via `dev project.new` (punk::mix::commandset::project::new)
receive a snapshot of punkshell's layout system: make.tcl/build.tcl, bootsupport
modules and libs, template payloads. That snapshot then drifts. The current
remedy is a push from punkshell: re-running `dev project.new -force 1 -update 1`
against the derived project. A pull - one command run inside the derived
project - is the more natural shape: the derived project knows when it wants
updating, and its owner is the right party to review what changes.
Motivating incident (2026-07-06, tomlish project): a make.tcl vintage 2025-04
carried a bootsupport-update step that itself depended on a stale bundled
fileutil::traverse (Tcl 8.x-only constraint, broken under 9.0.3), so the tool
that should fix the drift was disabled by the drift; bootsupport/lib still had
the obsolete per-platform layout; include_modules.config files were empty
template stubs. Recovery required manual cross-project copying - exactly the
push-shaped work this goal eliminates. A drifted derived project must be
recoverable by a pull whose own machinery does not depend on the freshness of
the drifted parts (the pull entrypoint should be small/self-contained enough to
work from a stale project, or delivered as its first step).
## Current mechanism and its limits
- .punkcheck records in the derived project record where targets were installed
from (installer/source paths) - "roughly the right shape": provenance-driven,
change-detected, skippable when unchanged.
- Limits:
- Source references are local relative paths - resolution breaks or silently
mislocates if the derived project (or punkshell checkout) moves.
- No VCS awareness: nothing checks whether the punkshell source is dirty
(G-026 territory) or whether the pull would clobber uncommitted local
modifications in the derived project.
- Push-only initiation, from the punkshell side.
## Approach
- A pull entrypoint in the derived project (make.tcl subcommand or a small
standalone bootstrap that does not depend on the possibly-stale local
infrastructure).
- Provenance robust to moves: record the origin punkshell project by something
more durable than a relative path - candidates: fossil project-code / git
remote or commit identity, plus a locally-resolvable path hint refreshed on
each pull; `dev projects.work` discovery (G-016/G-017) can re-locate a moved
punkshell checkout by name/id when the stored path fails.
- VCS integration on both ends:
- Source side: G-026 clean-checkout policy shared, not reimplemented.
- Target side: before overwriting, classify each target file - unmodified
since last punkcheck install (safe), locally modified but committed
(report/confirm), uncommitted local changes (refuse or explicit override).
- Remote pulls: decide whether updates may come directly from a remote
punkshell repository (fossil clone/sync or git fetch of a punkshell repo,
or the G-006 artifact-download channel) rather than a local checkout.
Considerations: provenance/verification of remote content, version pinning
(pull a tagged punkshell release vs trunk), and interaction with the
consent-gating principle of G-006. May be recorded as deferred with
rationale.
## Alternatives considered
- Keeping push-only and just fixing its path robustness - rejected as the end
state: the updater lives on the wrong side of the relationship; the derived
project's owner should initiate and review. Push retained during transition.
- Making derived projects git/fossil-track punkshell as an upstream remote and
merge - rejected: derived projects are independent repos with their own
history; infrastructure files are a curated payload, not a branch to merge.
## Notes
- Related: G-012 (inert template payloads and the make.tcl template-refresh
step feed what a pull delivers), G-016/G-017 (project discovery for
re-locating a moved origin), G-026 (clean-checkout policy on the source),
G-005/G-006 (remote artifact channels if remote pull proceeds).
- The 2025-04 tomlish checkin "update bootsupport and make.tcl from punkshell"
and the 2026-07-06 manual recovery are the concrete push-model precedents to
test the pull against: a scratch project seeded at an old layout vintage
should be fully recoverable by one pull.

67
goals/G-028-file-locker-identification.md

@ -0,0 +1,67 @@
# G-028 Name the process locking a file when builds cannot replace a target
Status: proposed
Scope: src/modules/punkboot/utils-999999.0a1.0.tm (locker-report helper), src/make.tcl (kit deploy failure reporting)
Acceptance: as in root GOALS.md index (canonical).
## Context
Motivating incident (2026-07-07, tomlish project): `make.tcl project` failed
with "could not delete target binary at .../bin/tomlish.exe" and no further
information. The lock turned out to be 7-Zip holding the exe open for archive
inspection - but identifying that took several dead ends, because Windows has
no cheap reverse lookup from file to holding process:
- tasklist / Get-Process by name only finds processes *running as* the file.
- Scanning Process.Modules only sees files mapped as images (loaded EXE/DLLs),
not plain open handles, and silently skips inaccessible processes.
- Full handle enumeration (NtQuerySystemInformation) needs elevation and
per-handle queries; `openfiles` requires a globally pre-enabled tracking
flag; Sysinternals handle.exe / Resource Monitor are external/manual.
The **Restart Manager API** (RmStartSession/RmRegisterResources/RmGetList) is
the practical programmatic answer: it is what installers use for the "these
applications are using this file" dialog, does not require elevation for the
common cases, and directly answers "who is blocking my replace/delete". It is
callable from Tcl via twapi or cffi, both already shipped in the punk Windows
kit.
Deploy-target locks will recur - G-023's versioned/dev binary outputs are
rewritten every build, and a running or inspected punk8/9-dev.exe is exactly
the kind of file this fails on.
## Approach
- `punkboot::utils` helper (e.g. file_lockers <path>) returning a list of
{pid <n> name <procname> ...} records, empty when unlocked.
- **Constraint: bootsupport carries no compiled extensions** (src/bootsupport
AGENTS.md). punkboot::utils stays pure Tcl; twapi (RmStartSession et al via
its API coverage) or cffi (direct Rstrtmgr.dll binding) is package-required
lazily inside the helper, from the environment's auto_path. Unavailable
binding, non-Windows platform, or API failure all yield a clean
'unavailable' result the caller renders as the existing message.
- make.tcl kit deploy (and any other replace-target failure sites worth
covering) appends the locker report to its failure output when available.
- Prefer twapi if its Restart Manager coverage suffices; cffi binding as the
fallback implementation choice - record the choice here when made.
## Alternatives considered
- Full handle-table enumeration (NtQuerySystemInformation) - rejected:
elevation-dependent, expensive, and reimplements what Restart Manager
already provides for the blocking-file case.
- Shelling out to Sysinternals handle.exe - rejected: external download,
license prompt on first run, elevation required; not dependable build
tooling.
- `openfiles` - rejected: requires a pre-enabled global tracking flag with a
standing performance cost.
## Notes
- Related: G-023 (versioned binary deploys are the recurring customer),
G-020 (same optionally-available twapi/cffi pattern for platform features),
punkboot::utils chicken-and-egg ordering rules in src/bootsupport/AGENTS.md
apply when adding the helper.
- Unix analogues (lsof/fuser) could slot behind the same helper interface
later; out of scope for acceptance (Windows is where the pain is and where
the API answer is non-obvious).

77
goals/G-029-testmodules-from-srctests.md

@ -0,0 +1,77 @@
# G-029 Build packaged test::<modulename> #modpod modules from src/tests
Status: proposed
Scope: src/make.tcl (test-module packaging step), src/tests/ (source of truth), src/modules/test/ (generated #modpod targets), src/modules/punk/mix/ (modpod tooling as needed)
Acceptance: as in root GOALS.md index (canonical).
## Context
Module tests currently exist in two forms:
1. **src/tests/modules/<namespacepath>/testsuites/** - the source-tree harness
form: self-contained tcltest files (own package requires, final
cleanupTests, self-locating data) run via src/tests/runtests.tcl. This is
where test development happens and is the intended structure going forward.
2. **src/modules/test/#modpod-<module>-<ver>/** - the packaged form: a
test::<modulename> module (wrapper .tm providing a SUITE/RUN interface and
locating its bundled *_testsuites) that ships in kits and is run in built
executables, e.g `tomlish.exe -app test`.
The packaged form is not just kit plumbing - it is a **distributable for
module consumers**: a user who downloads a built module can optionally
download the matching test::<modulename> package and verify the module's
behaviour on their own system/platform, with no source tree or test harness -
just `package require test::<modulename>` + its RUN interface, or a wrapped
executable's `-app test`. That end-user scenario is what fixes the packaged
form's requirements: fully self-sufficient (suites + data bundled), locatable
via ordinary module paths, and runnable in a bare interp.
Nothing generates one form from the other, so they drift as parallel
hand-maintained copies. The tomlish project (2026-07-07) is the live example:
its modpod testsuites were ported to src/tests (with fixes -
self-containment, single cleanupTests, self-located data), while the modpod
remains the source the built test::tomlish module actually comes from. Both
are now fossil-tracked and must be kept in sync by hand until this goal lands.
## Approach
- A make.tcl step (natural companion to the existing module build steps)
packages src/tests/modules/<namespacepath>/testsuites/** into the
test::<modulename> #modpod - punkcheck-tracked so unchanged sources skip.
- The wrapper .tm (SUITE/RUN interface, suitesdir resolution) becomes part of
the generation: either templated per module or a shared runtime the modpod
references. Its interface must keep serving the -app test dispatch used by
wrapped executables.
- Because src/tests files are self-contained, the packaged copies can be
byte-identical file copies; the wrapper supplies discovery (suite names from
directory structure) rather than environment variables the files depend on.
Legacy wrapper variables (e.g ::test::tomlish::suitesdir) should not be
reintroduced into test files - self-containment is the contract.
- Data fidelity: suite data (e.g roundtrip toml files with deliberate
crlf/mixed line endings) must be copied byte-for-byte; the #modpod directory
form is plain files, so no archive-level transformation should apply.
- Aggregate suites in the old modpods (fauxlink-based tests/ and dev/
collections) are a generation-time decision: reproduce via fauxlink, replace
with wrapper-level suite selection, or drop - record the choice here.
- Naming: test::<modulename> derives from the src/tests path
(modules/tomlish -> test::tomlish, modules/punk/args -> test::punk::args).
Only modules with testsuites content get a package generated.
## Alternatives considered
- Making the packaged modpod the source of truth and generating src/tests
from it - rejected: src/tests is where development happens (runtests.tcl
ergonomics, agent workflows, per-suite layout) and its self-contained-file
contract is the healthier base; the wrapper form is derivable, the reverse
requires stripping wrapper dependencies (as the tomlish port demonstrated).
- Keeping dual hand-maintained copies - rejected: that is the current drift
the tomlish incident demonstrated; sync-by-discipline does not hold.
## Notes
- Related: the tomlish project's src/tests port (its fossil checkins 20bd00dc,
feedb6f0 record the dual-copy interim and name this goal); punkshell's own
src/modules/test/ packages are further targets once the mechanism exists.
- The packaging step's output is a build artifact by policy even though it
lives under src/ - the punkcheck records make regeneration cheap and
hand-edits detectable.

8
manifest

@ -1,8 +0,0 @@
C initial\sempty\scheck-in
D 2025-09-27T18:28:21.559
R d41d8cd98f00b204e9800998ecf8427e
T *branch * trunk
T *sym-trunk *
U jnoble
Z 3f45351c1dc810e1e3525545cb4124cd
# Remove this line to create a well-formed Fossil manifest.

2
manifest.tags

@ -1,2 +0,0 @@
branch trunk
tag trunk

1
manifest.uuid

@ -1 +0,0 @@
8617f402ca78b8a1ae3566af83ccb97bec705e0b273c1ec5e76e8c6aa276a182

1
punkproject.toml

@ -1,2 +1,3 @@
[project]
name = "punkshell"
version = "0.2.6"

3
src/AGENTS.md

@ -63,7 +63,7 @@ Recovery after a wrong path guess:
- Use `tclsh src/make.tcl modules` to build just the module packages.
- Use `tclsh src/make.tcl libs` to build just the library packages.
- Use `tclsh src/make.tcl packages` to build both modules and libraries.
- Use `tclsh src/make.tcl vendorupdate` to refresh vendormodules from config.
- Use `tclsh src/make.tcl vendorupdate` to refresh vendormodules from config. It warns (non-fatal) for each source project whose fossil/git checkout is dirty - vendored artifacts built from a dirty tree have no committed provenance; commit in the source project to clear the warning (enforcement policy tracked by goal G-026).
- Use `tclsh src/make.tcl vfscommonupdate` to rebuild `_vfscommon.vfs`.
- Use `punk make.tcl project` or `punk902z make.tcl project` inside Punk shell when building binaries through Punk.
- Binary images are platform-specific; build on each target platform rather than expecting a cross-platform flag.
@ -79,6 +79,7 @@ Recovery after a wrong path guess:
- When touching VFS payloads, describe regeneration steps in durable docs if the workflow changes.
- When a tool summary reports cleanup issues such as `git diff --check` whitespace errors, verify against exact command output and exit code before running secondary scans or reporting the issue.
- `make.tcl` performs version-aware bootsupport staleness detection: major bumps abort, minor bumps prompt (y/N), patch bumps warn-and-proceed. See `src/bootsupport/AGENTS.md` "Bootsupport Staleness Handling" for the full contract and the version-bump discipline agents must follow.
- Project-level version bumps are governed by the root `AGENTS.md` "Project Versioning" section. Any `src/` change that ships user-visible shell behaviour requires a `punkproject.toml` version bump and a `CHANGELOG.md` entry as part of the DOX closeout pass. Run `tclsh src/make.tcl projectversion` to verify.
## Verification

13
src/bootsupport/AGENTS.md

@ -50,6 +50,19 @@ After changing any build-critical module (`punkcheck`, `punk::repo`, `punk::mix`
- Do not add compiled extensions or packages requiring dynamic loading to bootsupport.
- Check `src/bootsupport/modules/README.md` for the copy-as-module workflow.
### Moving make.tcl functionality into punkboot::utils
`punkboot::utils` (source of truth `src/modules/punkboot/utils-999999.0a1.0.tm`) exists to keep make.tcl from growing unwieldy. Only post-boot functionality may move there — make.tcl loads it lazily from the bootsupport snapshot after boot has set the module paths; anything used during early boot (path setup, `::punkboot::lib` prompts/classifiers, package-availability checks) must stay self-contained in make.tcl.
Chicken-and-egg-safe ordering when adding to punkboot::utils (make.tcl must never reference a proc its loaded snapshot lacks):
1. Edit `src/modules/punkboot/utils-999999.0a1.0.tm` and bump its buildversion.
2. `tclsh src/make.tcl modules` (build to root `modules/`).
3. `tclsh src/make.tcl bootsupport` (pull the new build into `src/bootsupport/modules/`).
4. Only then repoint make.tcl call sites to the new proc.
make.tcl call sites must use a guarded `package require punkboot::utils` and degrade gracefully (skip the feature with a warning) when the proc is unavailable — a stale or broken bootsupport snapshot must never brick the make.tcl commands used to repair it (`modules`, `bootsupport`).
## Verification
- `tclsh src/make.tcl packages` succeeds after any modification.

1
src/bootsupport/modules/include_modules.config

@ -53,6 +53,7 @@ set bootsupport_modules [list\
modules flagfilter\
modules funcl\
modules punk\
modules punkboot::utils\
modules punk::pipe\
modules punkapp\
modules punkcheck\

159
src/vfs/_vfscommon.vfs/modules/punk/console-0.6.0.tm → src/bootsupport/modules/punk/console-0.7.1.tm

@ -7,7 +7,7 @@
# (C) 2023
#
# @@ Meta Begin
# Application punk::console 0.6.0
# Application punk::console 0.7.1
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
@ -17,7 +17,7 @@
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::console 0 0.6.0]
#[manpage_begin punkshell_module_punk::console 0 0.7.1]
#[copyright "2024"]
#[titledesc {punk console}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk console}] [comment {-- Description at end of page heading --}]
@ -564,6 +564,30 @@ namespace eval punk::console {
set cinfo [punk::console::console_spec_resolve $inoutchannels]
set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]]
lassign $inoutchannels input output
#G-007 choke-point brokering: a query on a console owned by another thread runs in
#the owning thread - where the cooperative reader protocol (input_chunks_waiting),
#raw-mode arbitration and the authoritative settled can_respond live. Forwarding the
#whole call means the queueing, raw cycling and reader cooperation below all execute
#in the owner's context, and every query proc layered above this choke point
#inherits the routing. Routed before the local can_respond gate deliberately: a
#non-owner context's anchored view of the default console may be unsettled while
#the owner's is settled. The synchronous thread::send is safe because the owner
#services events while this thread blocks (the property the repl-installed vt52
#alias transport already relies on), and re-entry in the owner resolves to
#owner==self so the forward cannot ping-pong.
set route_owner [console_route_owner $inoutchannels]
if {$route_owner ne ""} {
try {
return [thread::send $route_owner [list ::punk::console::internal::get_ansi_response_payload -console $inoutchannels -expected_ms $expected -ignoreok $ignoreok -return $returntype -passthrough $passthrough $query $capturingendregex]]
} on error {errM erropts} {
#preserve the owner-side errorcode (where the transport propagates it) so
#callers can discriminate, e.g. {PUNK CONSOLE QUERY HOSTAGE_COOKED_READ}
#from the idle-reader guard
return -code error -errorcode [dict get $erropts -errorcode] "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM"
}
}
set governing_obj [dict get $cinfo object]
if {$governing_obj ne ""} {
set governing_cr [::opunk::Console::get.o_can_respond $governing_obj]
@ -590,6 +614,28 @@ namespace eval punk::console {
error "punk::console::get_ansi_response_payload input channel '$input' is closed or at eof - cannot receive terminal response"
}
#tcl 8.6 windows console idle-reader hostage guard (fail fast, no emission).
#With the console in cooked (line) mode and a readable handler armed on the input
#channel, the 8.6 channel driver has a blocking cooked-mode ReadConsole parked
#(arming is what posts it), and driver reads sample the console mode at issue time -
#the raw cycle below cannot rescue it. Any response would be swallowed by that read
#until the user presses Enter, then leak to the line reader as phantom input
#(~500ms timeout now, input corruption later). This is the idle-at-a-line-mode-prompt
#condition: the repl disarms its reader during command dispatch, so mid-command
#queries are unaffected, and raw mode reads cooperatively and is exempt. Typical
#trigger: a query from an after-script or worker thread firing while the shell sits
#at a line-mode prompt. Best-effort by design: a parked read can outlive a removed
#handler, so this catches the systematic case, not every conceivable one.
if {![tsv::get punk_console is_raw] && $input eq "stdin" && $::punk::console::has_twapi} {
set guard_conf [chan configure $input]
if {![dict exists $guard_conf -inputmode] && ![dict exists $guard_conf -mode]
&& [chan event $input readable] ne ""
&& ![catch {twapi::get_console_handle stdin}]} {
return -code error -errorcode [list PUNK CONSOLE QUERY HOSTAGE_COOKED_READ]\
"punk::console::get_ansi_response_payload refusing to emit query on '$input': tcl 8.6 console is in cooked (line) mode with an armed readable handler - the driver's parked cooked read would swallow the response until Enter (query issued from an idle line-mode prompt?). Use raw mode, or issue queries while the reader is disarmed (e.g. during repl command evaluation)."
}
}
#chunks from input that need to be handled by readers
upvar ::punk::console::input_chunks_waiting input_chunks_waiting
@ -684,6 +730,16 @@ namespace eval punk::console {
#For mintty-without-winpty (terminal presenting pipes) the twapi raw cycle was already a
#no-op via its get_console_handle early-return, so nothing is lost by skipping.
set input_is_console_or_tty [expr {[dict exists $previous_input_state -inputmode] || [dict exists $previous_input_state -mode]}]
if {!$input_is_console_or_tty && $input eq "stdin" && $::punk::console::has_twapi} {
#Tcl 8.6 windows console channels have no -inputmode configure key, so the
#dict test alone misclassifies a real 8.6 console as a pipe: the raw cycle
#is then skipped and the query response sits in the cooked line buffer until
#Enter - timeout here plus the response leaking to the line reader as phantom
#input. A twapi console handle for stdin is definitive. Channels other than
#stdin stay excluded - the guard's purpose (pipe probes must not flip the
#process console) is unchanged.
set input_is_console_or_tty [expr {![catch {twapi::get_console_handle stdin}]}]
}
if {![tsv::get punk_console is_raw]} {
set was_raw 0
if {$input_is_console_or_tty} {
@ -1988,6 +2044,16 @@ namespace eval punk::console {
#pending keystroke with a probe read here.
return 0
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key, so the dict test above
#misses them. Beyond the pending-keystroke concern, the probe read is fatal here:
#a read on a drained 8.6 console channel makes the channel driver park a blocking
#cooked-mode ReadConsole that cannot be cancelled by a later raw-mode flip - it
#swallows a terminal query response emitted afterwards (get_ansi_response_payload
#calls this guard immediately before its raw cycle) and only completes on Enter.
return 0
}
#pipe-like channel - probe without blocking to force eof detection
if {[catch {
set prior_blocking [dict get $conf -blocking]
@ -2036,6 +2102,14 @@ namespace eval punk::console {
#tty on unix-like platforms
return 1
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key - a twapi console
#handle for stdin is definitive: process stdin is the real console. Terminals
#presenting channels as pipes (mintty without winpty) have no console handle
#and fall through to the env heuristics below.
return 1
}
if {[input_at_eof $input]} {
return 0
}
@ -2405,6 +2479,37 @@ namespace eval punk::console {
set owner [punk::console::console_owner_get {stdin stdout}]
return [expr {$owner eq "" || $owner eq [thread::id]}]
}
punk::args::define {
@id -id ::punk::console::internal::console_route_owner
@cmd -name punk::console::internal::console_route_owner -summary\
"Owning thread a console operation must be brokered to, or empty string to operate locally."\
-help\
"Routing consults the ownership registry for the process-default console
{stdin stdout} only: std channel names are process-wide, so any context can
refer to the shared console, but exactly one context owns its reader and
raw-mode arbitration. Non-std channel names are thread-local - an {in out}
pair spec names channels of the calling thread, making that thread the
console's local context - so non-default pairs always operate locally
(a registry entry for such a pair qualifies the shared fact store; it is
not a routing target). A console anchored by code-interp/worker code is
therefore operated on directly by its anchoring context, with no round-trip.
Returns empty string when the caller is the owner or no live owner is
registered."
@values -min 1 -max 1
inoutchannels -type list -help\
"Canonical {in out} channel pair."
}
proc console_route_owner {inoutchannels} {
#see PUNKARGS id ::punk::console::internal::console_route_owner
if {[lindex $inoutchannels 0] ne "stdin" || [lindex $inoutchannels 1] ne "stdout"} {
return ""
}
set owner [punk::console::console_owner_get {stdin stdout}]
if {$owner eq "" || $owner eq [thread::id]} {
return ""
}
return $owner
}
punk::args::define {
@id -id ::punk::console::internal::console_share_qualifier
@cmd -name punk::console::internal::console_share_qualifier -summary\
@ -2809,7 +2914,14 @@ namespace eval punk::console {
proc ensure_object_integration {} {
variable object_integration_done
if {$object_integration_done} {
return
return 1
}
if {![namespace exists ::opunk::console]} {
#opunk::console not loaded - nothing to integrate with yet. In-module callers only
#reach here after a successful 'package require opunk::console'; external/manual
#callers may call speculatively. Deliberately does not set object_integration_done,
#so a later call after opunk::console loads performs the wiring.
return 0
}
set ::opunk::console::waiting_chunks_arrayvar ::punk::console::input_chunks_waiting
if {$::opunk::console::size_query_provider eq ""} {
@ -2820,8 +2932,27 @@ namespace eval punk::console {
#consult-time liveness validation still cover the default console there)
if {[info exists ::opunk::console::lifecycle_callback] && $::opunk::console::lifecycle_callback eq ""} {
set ::opunk::console::lifecycle_callback [list ::punk::console::internal::object_console_lifecycle]
#catch-up registration: anchors created in this interp before the callback was
#wired never fired a 'created' event, so their consoles have no recorded owner
#(seen when user code does 'package require opunk::console; opunk::console::create ...'
#before any punk::console object operation triggers this wiring). Anchors are
#per-interp/per-thread, so the anchoring context is this thread - register it.
#Only fills empty entries: an existing live registration is newer information than
#these pre-wiring anchors (and for the default console first-registration wins).
foreach v [info vars ::opunk::console::instances::*] {
if {![info exists $v]} {
continue
}
if {[catch {::opunk::Console::channels [set $v]} channels]} {
continue
}
if {[console_owner_get $channels] eq ""} {
console_owner_register $channels
}
}
}
set object_integration_done 1
return 1
}
lappend PUNKARGS [list {
@ -2892,12 +3023,19 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::move 2000 2000]
#flush before querying: the position query may execute in the console-owning
#thread (G-007 routing), whose flush acts on its own channel instance for the same
#OS handle - an unflushed move here would leave the terminal reporting the
#unmoved cursor position (seen as 'columns 1' on tcl 8.6 where the -winsize
#shortcut is unavailable and this mechanism actually runs)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::move $start_row $start_col][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::move $start_row $start_col]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -2931,12 +3069,16 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::cursor_save_dec][punk::ansi::move 2000 2000]
#flush before querying - see get_size_using_cursormove (G-007 routing: the query
#may flush a different channel instance in the console-owning thread)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::cursor_restore][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::cursor_restore_dec]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -4186,6 +4328,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before each cursor query: the query may execute in the console-owning thread
#(G-007 routing) whose flush acts on its own channel instance - unflushed emissions
#here would be measured as if they never happened (see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -4207,6 +4353,7 @@ namespace eval punk::console {
} errM]} {
puts stderr "test_char_width couldn't emit this string - \nerror: $errM"
}
flush $out ;#the test emission must reach the terminal before the (possibly routed) query measures it
set response [punk::console::get_cursor_pos $inoutchannels]
lassign [split $response ";"] _row2 col2
@ -4261,6 +4408,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before the cursor query - the alt-screen/move/erase emissions above must reach
#the terminal first (the query may flush a different channel instance in the
#console-owning thread under G-007 routing - see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -6202,7 +6353,7 @@ namespace eval ::punk::args::register {
## Ready
package provide punk::console [namespace eval punk::console {
variable version
set version 0.6.0
set version 0.7.1
}]
return

51
src/bootsupport/modules/punk/repl-0.2.1.tm → src/bootsupport/modules/punk/repl-0.2.2.tm

@ -1635,6 +1635,7 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
#note -inputmode not available in Tcl 8.6 for chan configure!
#According to DKF - -buffering option doesn't affect input channels
set rawmode 0
set waiting_needs_reader 0 ;#set when the 8.6-console waiting-chunks path skips its read on a drained channel - the readable handler must be armed (not the after-idle reinvoke) or the pending partial line could never complete
set original_input_conf [chan configure $inputchan] ;#whether repl is in line or raw mode - we restore the inputchan (stdin) state
if {[dict exists $original_input_conf -inputmode]} {
@ -1693,9 +1694,46 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set chunk [read $inputchan]
set hostage_noread 0
if {"windows" eq $::tcl_platform(platform)
&& ![dict exists $original_input_conf -inputmode]
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]} {
#tcl 8.6 windows console channel (no -inputmode configure key, real console
#handle). On the 8.6 console driver a read on a *drained* channel parks a
#blocking cooked-mode ReadConsole that a later raw-mode flip cannot cancel:
#it swallows terminal query responses until Enter (see punk::console 0.7.1
#buildversion notes). This waiting-chunks path is normally entered via
#'after idle' with the channel drained - only consume what is already in the
#Tcl channel buffer (sized read - no driver probe); when nothing is buffered,
#process the stashed complete lines without reading and arm the readable
#handler for the rest.
set avail [chan pending input $inputchan]
if {$avail > 0} {
set chunk [read $inputchan $avail]
} else {
set chunk ""
set hostage_noread 1
set waiting_needs_reader 1
}
} else {
set chunk [read $inputchan]
}
set chunksize [string length $chunk]
if {$chunksize > 0} {
if {$hostage_noread} {
#nothing in the Tcl buffer and we must not probe the drained driver.
#Process the complete stashed lines now; a remaining partial line can only
#progress via more user input, which the armed readable handler delivers.
if {[llength $stdinlines]} {
punk::repl::repl_handler_restorechannel_if_not_eof $inputchan $original_input_conf
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
}
if {$waitingchunk ne ""} {
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set input_chunks_waiting($inputchan) [list]
}
} elseif {$chunksize > 0} {
if {[string index $chunk end] eq "\n"} {
lappend stdinlines $waitingchunk[string range $chunk 0 end-1]
#punk::console::cursorsave_move_emitblock_return 30 30 "repl_handler num_stdinlines [llength $stdinlines] chunk:$yellow[ansistring VIEW -lf 1 $chunk][a] fblocked:[fblocked $inputchan] pending:[chan pending input stdin]"
@ -1864,7 +1902,12 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
##################################################################################
#Re-enable channel read handler only if no waiting chunks - must process in order
##################################################################################
if {![llength $input_chunks_waiting($inputchan)]} {
if {![llength $input_chunks_waiting($inputchan)] || $waiting_needs_reader} {
#waiting_needs_reader: the 8.6-console waiting-chunks path found nothing in the
#Tcl channel buffer and must not probe the drained driver - the waiting data is
#an incomplete line that can only progress when the user types more, so arm the
#reader (the driver listening for input is its job here) instead of an after-idle
#reinvoke that could never make progress.
chan event $inputchan readable [list ::repl::repl_handler $inputchan $readmore $prompt_config]
} else {
#review
@ -4172,7 +4215,7 @@ namespace eval repl {
}
package provide punk::repl [namespace eval punk::repl {
variable version
set version 0.2.1
set version 0.2.2
}]
#repl::start $program_read_stdin_pipe

214
src/bootsupport/modules/punkboot/utils-0.1.1.tm

@ -0,0 +1,214 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use 'pmix make' or src/make.tcl to update from <pkg>-buildversion.txt
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2023
#
# @@ Meta Begin
# Application punkboot::utils 0.1.1
# Meta platform tcl
# Meta license BSD
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
##e.g package require frobz
package require Tcl 8.6-
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
namespace eval punkboot::utils {
variable version 0.1.0
namespace export parse_punkproject_version read_punkproject_version read_changelog_latest_version vcs_dirty_warnings
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::parse_punkproject_version
@cmd -name "::punkboot::utils::parse_punkproject_version"\
-summary\
"Parse the [project] version from TOML content string"\
-help\
"Scans TOML content for the [project] section and returns
the value of the version key as a string. Returns an empty
string if the section or key is not found."
@leaders
content -type string -optional 0 -help\
"TOML content string to parse"
}]
}
proc parse_punkproject_version {content} {
set in_project 0
foreach line [split $content \n] {
set trimmed [string trim $line]
if {[string index $trimmed 0] eq {[} && [string index $trimmed end] eq {]}} {
set in_project [expr {$trimmed eq "\[project\]"}]
continue
}
if {$in_project && [regexp {^version\s*=\s*"([^"]+)"} $trimmed _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_punkproject_version
@cmd -name "::punkboot::utils::read_punkproject_version"\
-summary\
"Read the [project] version from a TOML file"\
-help\
"Reads the file at tomlfile and returns the [project]
version value as a string. Returns an empty string if the
file does not exist or the version is not found."
@leaders
tomlfile -type string -optional 0 -help\
"Path to the TOML file to read"
}]
}
proc read_punkproject_version {tomlfile} {
if {![file exists $tomlfile]} {return ""}
set fh [open $tomlfile r]
try {
set content [read $fh]
} finally {
close $fh
}
return [::punkboot::utils::parse_punkproject_version $content]
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_changelog_latest_version
@cmd -name "::punkboot::utils::read_changelog_latest_version"\
-summary\
"Read the latest ## [X.Y.Z] version header from a changelog file"\
-help\
"Scans the changelog file from the top and returns the
version string from the first '## [X.Y.Z]' header line.
Returns an empty string if the file does not exist or no
matching header is found."
@leaders
changelogfile -type string -optional 0 -help\
"Path to the changelog file to read"
}]
}
proc read_changelog_latest_version {changelogfile} {
if {![file exists $changelogfile]} {return ""}
set fh [open $changelogfile r]
try {
set content [read $fh]
} finally {
close $fh
}
foreach line [split $content \n] {
if {[regexp {^##\s*\[([0-9][^\]]*)\]} $line _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::vcs_dirty_warnings
@cmd -name "::punkboot::utils::vcs_dirty_warnings"\
-summary\
"Warning lines for uncommitted VCS changes above a path"\
-help\
"Walks up from path to the nearest fossil and/or git root and
returns warning lines describing uncommitted changes.
Artifacts built from a dirty source tree have no committed
provenance - callers such as make.tcl vendorupdate use this
to flag source projects that should be committed first.
checkedrootsvar names a dict variable in the caller's scope
used to report each VCS root at most once across calls.
label, if supplied, is prefixed into each warning to name
the calling operation (e.g. 'vendorupdate').
Returns an empty list if the path is clean, unversioned,
missing, already reported, or state cannot be determined."
@leaders
path -type string -optional 0 -help\
"Path to check; the nearest enclosing fossil/git root is examined"
checkedrootsvar -type string -optional 0 -help\
"Name of a dict variable in the caller's scope for dedupe across calls"
label -type string -optional 1 -default "" -help\
"Operation name prefixed into warnings"
}]
}
proc vcs_dirty_warnings {path checkedrootsvar {label ""}} {
upvar 1 $checkedrootsvar checkedroots
if {![info exists checkedroots]} {set checkedroots [dict create]}
if {$label ne ""} {set label "$label "}
set warnings [list]
set dir [file normalize $path]
if {![file isdirectory $dir]} {
return $warnings ;#missing path - not this proc's business to report
}
set fossilroot ""
set gitroot ""
while {1} {
if {$fossilroot eq "" && ([file exists [file join $dir .fslckout]] || [file exists [file join $dir _FOSSIL_]])} {
set fossilroot $dir
}
if {$gitroot eq "" && [file exists [file join $dir .git]]} {
set gitroot $dir
}
if {$fossilroot ne "" || $gitroot ne ""} {
break ;#report at the nearest VCS root(s); a dual git+fossil root is caught in one pass
}
set parent [file dirname $dir]
if {$parent eq $dir} { break }
set dir $parent
}
if {$fossilroot ne "" && ![dict exists $checkedroots fossil,$fossilroot] && [llength [auto_execok fossil]]} {
dict set checkedroots fossil,$fossilroot 1
#fossil changes must run from within the checkout
set original_cwd [pwd]
if {[catch {
cd $fossilroot
set fchanges [string trim [exec {*}[auto_execok fossil] changes]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine fossil state of source project at $fossilroot ($errM)"
} elseif {$fchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $fossilroot has uncommitted fossil changes ([llength [split $fchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
cd $original_cwd
}
if {$gitroot ne "" && ![dict exists $checkedroots git,$gitroot] && [llength [auto_execok git]]} {
dict set checkedroots git,$gitroot 1
if {[catch {
set gchanges [string trim [exec {*}[auto_execok git] -C $gitroot status --porcelain]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine git state of source project at $gitroot ($errM)"
} elseif {$gchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $gitroot has uncommitted git changes ([llength [split $gchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
}
return $warnings
}
}
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punkboot::utils ::punkboot::utils::argdoc
}
## Ready
package provide punkboot::utils [tcl::namespace::eval punkboot::utils {
variable version
#- this version number, exactly 0.1.1, is a literal used in src module folders
#- we refer to this sometimes as the magic version number
set version 0.1.1
}]
return

42
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/shellfilter-0.2.2.tm → src/bootsupport/modules/shellfilter-0.2.3.tm

@ -2849,9 +2849,19 @@ namespace eval shellfilter {
set id_out [shellfilter::stack::add $outchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_out -pipechan $outpipechan]]
set id_err [shellfilter::stack::add $errchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_err -pipechan $errpipechan]]
#From here to the stack::remove calls the caller's channels are diverted:
#any error escaping this region would leave stdout/stderr broken for the rest of the
#process with all subsequent output silently lost - hence the try/finally below, and
#catch-guards on decorative logging (stack-status table rendering runs punk::ansi/textblock
#code that can fail e.g when mismatched module snapshots are loaded - a rendering failure
#must degrade to a log note, never abort the run).
log::critical {
::shellfilter::log::write $runtag "[punk::ansi::ansistrip [shellfilter::stack status]]\nchan names:[chan names]"
if {[catch {punk::ansi::ansistrip [shellfilter::stack status]} stackstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $stackstatus)"
}
::shellfilter::log::write $runtag "$stackstatus\nchan names:[chan names]"
}
try {
# need to use os level channel handle for stdin - try named pipes (or even sockets) instead of fifo2 for this
# If non os-level channel - the command can't be run with the redirection
# stderr/stdout can be run with non-os handles in the call -
@ -2919,22 +2929,28 @@ namespace eval shellfilter {
#----------------------------------------------------------------------------------------------
#todo - change to log::debug
log::critical {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
if {[catch {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
}
} errstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $errstatus)"
}
::shellfilter::log::write $runtag "shellfilter::stack status after execution: \n$stackstatus\nchan:names [chan names]"
}
#----------------------------------------------------------------------------------------------
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
} finally {
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack - must happen even if the body above errored,
#or the caller's stdout/stderr stay diverted and all subsequent process output is silently lost.
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
}
log::info {::shellfilter::log::write $runtag " return '$exitinfo'"}
@ -3811,5 +3827,5 @@ namespace eval shellfilter {
package provide shellfilter [namespace eval shellfilter {
variable version
set version 0.2.2
set version 0.2.3
}]

421
src/bootsupport/modules/shellrun-0.1.2.tm → src/bootsupport/modules/shellrun-0.1.4.tm

@ -5,6 +5,7 @@
package require shellfilter
package require punk::ansi
package require punk::args
#NOTE: the run,runout,runerr,runx commands only produce an error if the command didn't run.
# - If it did run, but there was a non-zero exitcode it is up to the application to check that.
@ -79,76 +80,45 @@ namespace eval shellrun {
#maintenance: similar used in punk::ns & punk::winrun
#todo - take runopts + aliases as args
#longopts must be passed as a single item ie --timeout=100 not --timeout 100
proc get_run_opts {arglist} {
if {[catch {
set callerinfo [info level -1]
} errM]} {
set caller ""
} else {
set caller [lindex $callerinfo 0]
}
#we provide -nonewline even for 'run' even though run doesn't deliver stderr or stdout to the tcl return value
#This is for compatibility with other runX commands, and the difference is also visible when calling from repl.
set known_runopts [list "-echo" "-e" "-nonewline" "-n" "-tcl" "-debug"]
set known_longopts [list "--timeout"]
set known_longopts_msg ""
foreach lng $known_longopts {
append known_longopts_msg "${lng}=val "
}
set aliases [list "-e" "-echo" "-echo" "-echo" "-n" "-nonewline" "-nonewline" "-nonewline" "-tcl" "-tcl" "-debug" "-debug"] ;#include map to self
set runopts [list]
set runoptslong [list]
set cmdargs [list]
set idx_first_cmdarg [lsearch -not $arglist "-*"]
set allopts [lrange $arglist 0 $idx_first_cmdarg-1]
set cmdargs [lrange $arglist $idx_first_cmdarg end]
foreach o $allopts {
if {[string match --* $o]} {
lassign [split $o =] flagpart valpart
if {$valpart eq ""} {
error "$caller: longopt $o seems to be missing a value - must be of form --option=value"
}
if {$flagpart ni $known_longopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runoptslong $flagpart $valpart
} else {
if {$o ni $known_runopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runopts [dict get $aliases $o]
}
}
return [list runopts $runopts runoptslong $runoptslong cmdargs $cmdargs]
}
#todo - investigate cause of punk86 run hanging sometimes. An 'after 500' before exit in the called script fixes the issue. punk87 doesn't seem to be affected.
lappend PUNKARGS [list {
@id -id ::shellrun::run
@cmd -name "shellrun::run"\
-summary "Run an external command, streaming stdout/stderr to the console in real-time"\
-help\
"Run an external command and return the shellfilter exitinfo dict.
stdout and stderr are written to the console as they arrive;
nothing is captured for the return value. The exitcode is
part of the returned dict — a non-zero exitcode is NOT raised
as a Tcl error, the caller must check it explicitly.
Use -tcl to run a Tcl script/command instead of an external
process; in that case the returned dict carries a 'result'
key (or error/errorCode/errorInfo on failure) instead of an
exitcode."
@leaders -min 0 -max 0
@opts
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-nonewline -type none -help\
"Accepted for compatibility with runout/runerr/runx. Has no
effect on run because run does not capture or return stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc run {args} {
#set_last_run_display [list]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set runoptslong [dict get $splitargs runoptslong]
#set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::run]
lassign [dict values $argd] leaders opts values received
@ -206,11 +176,25 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runconsole
@cmd -name "shellrun::runconsole"\
-summary "Run a command via exec with stdout/stderr directed to the console, mimicking Tcl's unknown handler"\
-help\
"Resolve cmdname via auto_execok and exec it with stdout
and stderr redirected to the active console. The result is
returned via uplevel with the exec result/options dict,
mirroring the way Tcl's unknown proc handles external
commands.
Unlike run, this uses exec semantics — there is no
exitcode in the return value and stdout/stderr are not
captured separately."
@leaders -min 0 -max 0
@opts
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the command to resolve via auto_execok and run."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed to the resolved command."
}]
#run in the way tcl unknown does - but without regard to auto_noexec
proc runconsole {args} {
@ -256,16 +240,42 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runout
@cmd -name "shellrun::runout"\
-summary "Run an external command and return its stdout"\
-help\
"Run an external command, capture stdout and stderr, and
return stdout as the result. stderr and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stdout.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runout {args} {
set argd [punk::args::parse $args withid ::shellrun::runout]
@ -284,11 +294,6 @@ namespace eval shellrun {
set runerr ""
set RST [a]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
#puts stdout "RUNOUT cmdargs: $cmdargs"
#todo add -data boolean and -data lastwrite to -settings with default being -data all
@ -422,16 +427,42 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runerr
@cmd -name "shellrun::runerr"\
-summary "Run an external command and return its stderr"\
-help\
"Run an external command, capture stdout and stderr, and
return stderr as the result. stdout and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stderr.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runerr {args} {
set argd [punk::args::parse $args withid ::shellrun::runerr]
@ -449,10 +480,6 @@ namespace eval shellrun {
set runout ""
set runerr ""
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
@ -567,6 +594,43 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runx
@cmd -name "shellrun::runx"\
-summary "Run an external command and return a dict of stdout, stderr, and exitcode"\
-help\
"Run an external command, capture stdout and stderr, and
return a dict containing stdout, stderr, and exitcode (or
result/error/errorCode/errorInfo for -tcl calls).
No console output is produced unless -echo is given. Use
-nonewline to strip trailing newlines from captured stdout
and stderr before returning.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runx {args} {
#set_last_run_display [list]
variable runout
@ -574,11 +638,10 @@ namespace eval shellrun {
set runout ""
set runerr ""
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::runx]
lassign [dict values $argd] leaders opts values received
if {"-nonewline" in $runopts} {
if {[dict exists $received "-nonewline"]} {
set nonewline 1
} else {
set nonewline 0
@ -586,7 +649,7 @@ namespace eval shellrun {
#shellfilter::stack::remove stdout $::repl::id_outstack
if {"-echo" in $runopts} {
if {[dict exists $received "-echo"]} {
#float to ensure repl transform doesn't interfere with the output data
set stderr_stackid [shellfilter::stack::add stderr tee_to_var -action float -settings {-varname ::shellrun::runerr}]
set stdout_stackid [shellfilter::stack::add stdout tee_to_var -action float -settings {-varname ::shellrun::runout}]
@ -601,10 +664,23 @@ namespace eval shellrun {
set stdout_stackid [shellfilter::stack::add stdout var -action float-locked -junction 1 -settings {-varname ::shellrun::runout}]
}
set callopts ""
if {"-tcl" in $runopts} {
append callopts " -tclscript 1"
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
}
if {[dict exists $received "-debug"]} {
dict set callopts -debug 1
}
if {[dict exists $received --timeout]} {
dict set callopts -timeout [dict get $opts --timeout]
}
set cmdname [dict get $values cmdname]
if {[dict exists $received cmdarg]} {
set cmdarglist [dict get $values cmdarg]
} else {
set cmdarglist {}
}
set cmdargs [concat $cmdname $cmdarglist]
#set exitinfo [shellfilter::run $cmdargs -teehandle punksh -inbuffering none -outbuffering none -stdinhandler ::repl::repl_handler]
set exitinfo [shellfilter::run $cmdargs {*}$callopts -teehandle punksh -inbuffering none -outbuffering none]
@ -616,7 +692,7 @@ namespace eval shellrun {
flush stdout
if {[dict exists $exitinfo error]} {
if {"-tcl" in $runopts} {
if {[dict exists $received "-tcl"]} {
} else {
#todo - check errorInfo makes sense.. return -code? tailcall?
@ -826,30 +902,161 @@ namespace eval shellrun {
return $exitinfo
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_run
@cmd -name "shellrun::sh_run"\
-summary "Run a command line via sh -c, streaming stdout/stderr to the console"\
-help\
"Wrapper around shellrun::run that invokes the command line
via sh -c. The entire command line after options is passed
as a single argument to sh -c.
Accepts the same options as shellrun::run except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-nonewline -type none -help\
"Accepted for compatibility. Has no effect on run-based
commands because run does not capture output."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_run {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_run]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
#e.g sh -c "ls -l *"
#we pass cmdargs to sh -c as a list, not individually
tailcall shellrun::run {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runout
@cmd -name "shellrun::sh_runout"\
-summary "Run a command line via sh -c and return its stdout"\
-help\
"Wrapper around shellrun::runout that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runout except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runout {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runout]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runout {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runerr
@cmd -name "shellrun::sh_runerr"\
-summary "Run a command line via sh -c and return its stderr"\
-help\
"Wrapper around shellrun::runerr that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runerr except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runerr {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runerr]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runerr {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runx
@cmd -name "shellrun::sh_runx"\
-summary "Run a command line via sh -c and return a dict of stdout, stderr, and exitcode"\
-help\
"Wrapper around shellrun::runx that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runx except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runx {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runx]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runx {*}$runopts sh -c $cmdargs
}
}
@ -895,5 +1102,5 @@ namespace eval ::punk::args::register {
package provide shellrun [namespace eval shellrun {
variable version
set version 0.1.2
set version 0.1.4
}]

106
src/make.tcl

@ -22,7 +22,7 @@ namespace eval ::punkboot {
variable pkg_requirements [list]; variable pkg_missing [list];variable pkg_loaded [list]
variable non_help_flags [list -k]
variable help_flags [list -help --help /? -h]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate ]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate projectversion]
}
@ -1358,6 +1358,8 @@ proc ::punkboot::punkboot_gethelp {args} {
append h " - show module/library paths and any potentially problematic packages for running this script" \n
append h " $scriptname shell" \n
append h " - run the punk shell using bootsupport libraries." \n
append h " $scriptname projectversion" \n
append h " - advisory check: verify CHANGELOG.md matches punkproject.toml and warn if src/ has changes since the last project-version bump." \n \n
append h "" \n
if {[llength [dict get $pkg_availability missing]] || [llength [dict get $pkg_availability broken]]} {
set has_recommended 0
@ -1532,7 +1534,7 @@ if {[lsearch $::argv -k] >= 0} {
# - minor bump -> prompt user; backward-compat aliases *should* keep old bootsupport functional
# - patch bump -> warn and proceed (non-breaking)
# 'check' is exempt in all cases the warning is shown at the end of its output instead.
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check"} {
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check" && $::punkboot::command ne "projectversion"} {
set _stale $::punkboot::stale_bootsupport
set _have_major 0
set _have_minor 0
@ -1825,8 +1827,88 @@ if {$::punkboot::command eq "info"} {
exit 0
}
if {$::punkboot::command eq "projectversion"} {
package require punkboot::utils
set sep [string repeat - 75]
puts stdout $sep
puts stdout "project version check"
puts stdout $sep
set pp_file [file join $projectroot punkproject.toml]
set cl_file [file join $projectroot CHANGELOG.md]
set src_folder [file join $projectroot src]
set pp_version [::punkboot::utils::read_punkproject_version $pp_file]
set cl_version [::punkboot::utils::read_changelog_latest_version $cl_file]
puts stdout "punkproject.toml version : [expr {[string length $pp_version] ? $pp_version : "(not found)"}]"
puts stdout "CHANGELOG.md version : [expr {[string length $cl_version] ? $cl_version : "(not found)"}]"
# 1. Consistency: CHANGELOG latest header must match punkproject.toml version.
if {![string length $pp_version]} {
puts stderr "WARNING: could not read version from $pp_file"
} elseif {![string length $cl_version]} {
puts stderr "WARNING: no '## [X.Y.Z]' version header found in $cl_file"
} elseif {$pp_version ne $cl_version} {
puts stderr "WARNING: version mismatch punkproject.toml=$pp_version but CHANGELOG.md=$cl_version"
puts stderr " The latest '## [X.Y.Z]' header in CHANGELOG.md must match the version in punkproject.toml."
} else {
puts stdout "consistency: OK (versions match)"
}
# 2. Staleness: warn if src/ has git commits since the last commit
# touching punkproject.toml (version bump may be overdue).
# If punkproject.toml has an uncommitted version change, the bump is
# already in progress report that only a commit is needed.
# Best-effort silently skipped if git is unavailable or the repo
# is not under git revision control.
set pp_commit ""
if {![catch {exec git -C $projectroot log -1 --format=%H -- punkproject.toml} pp_raw]} {
set pp_commit [string trim $pp_raw]
}
if {[string length $pp_commit]} {
if {![catch {exec git -C $projectroot log --oneline ${pp_commit}..HEAD -- src/} src_raw]} {
set src_changes [string trim $src_raw]
if {[string length $src_changes]} {
set n [llength [split $src_changes \n]]
# Check whether a bump is already staged in the working tree
# (punkproject.toml modified but uncommitted). If the working-
# tree version differs from HEAD, the bump is done only a
# commit is needed.
set head_version ""
if {![catch {exec git -C $projectroot show HEAD:punkproject.toml} head_raw]} {
set head_version [::punkboot::utils::parse_punkproject_version $head_raw]
}
set bump_uncommitted [expr {[string length $head_version] && [string length $pp_version] && $pp_version ne $head_version}]
if {$bump_uncommitted} {
puts stderr "NOTE: $n commit(s) in src/ since last punkproject.toml commit, but punkproject.toml already bumped ($head_version -> $pp_version, uncommitted)."
puts stderr " Commit punkproject.toml (and CHANGELOG.md) to record the bump and clear this warning."
} else {
puts stderr "WARNING: $n commit(s) in src/ since last punkproject.toml change a project-version bump may be overdue."
puts stderr " Review the changes and bump punkproject.toml per the root AGENTS.md 'Project Versioning' section."
}
puts stderr " (showing first 5 commits below)"
set i 0
foreach line [split $src_changes \n] {
if {$i >= 5} break
puts stderr " $line"
incr i
}
} else {
puts stdout "staleness: OK (no src/ changes since last punkproject.toml commit)"
}
} else {
puts stdout "staleness: skipped (git query failed)"
}
} else {
puts stdout "staleness: skipped (punkproject.toml has no git history or git unavailable)"
}
puts stdout $sep
exit 0
}
if {$::punkboot::command eq "shell"} {
package require struct::list
@ -1890,10 +1972,20 @@ if {$::punkboot::command eq "vendorupdate"} {
#puts "-- [tcl::tm::list] --"
puts stdout "Updating vendor modules in src folder"
#dirty-checkout provenance warnings via punkboot::utils::vcs_dirty_warnings (loaded from bootsupport).
#Guarded require: vendorupdate must keep working even if the bootsupport punkboot::utils copy is
#missing or stale (it may be the very thing being repaired) - the check degrades to skipped.
set have_vcs_dirty_check [expr {![catch {package require punkboot::utils}] && [llength [info commands ::punkboot::utils::vcs_dirty_warnings]]}]
if {!$have_vcs_dirty_check} {
puts stderr "WARNING: vendorupdate dirty-checkout provenance check unavailable (punkboot::utils vcs_dirty_warnings not loadable from bootsupport) - continuing without it"
}
proc vendor_localupdate {projectroot} {
set local_modules [list]
set git_modules [list]
set fossil_modules [list]
set checked_source_paths [dict create] ;#skip repeat walk-ups for identical source paths
set checked_vcs_roots [dict create] ;#report each VCS root at most once per run
set sourcefolder $projectroot/src
#todo vendor/lib
set vendorlibfolders [glob -nocomplain -dir $sourcefolder -type d -tails vendorlib_tcl*]
@ -1939,6 +2031,16 @@ if {$::punkboot::command eq "vendorupdate"} {
} else {
set srclocation [file join $localpath $module_subpath]
}
#dirty-checkout provenance check - each VCS root reported at most once per run
if {$::have_vcs_dirty_check} {
set normsrclocation [file normalize $srclocation]
if {![dict exists $checked_source_paths $normsrclocation]} {
dict set checked_source_paths $normsrclocation 1
foreach w [::punkboot::utils::vcs_dirty_warnings $normsrclocation checked_vcs_roots vendorupdate] {
puts stderr $w
}
}
}
#puts stdout "$relpath $module $module_subpath $srclocation"
#todo - check if requested_module has version extension and allow explicit versions instead of just latest

1
src/modules/punk/AGENTS.md

@ -32,6 +32,7 @@ Source of truth for all modules under the `punk::*` namespace. This is the prima
- punk::console emit-side functions (the `punk::console::ansi::*` emit wrappers, mouse/paste toggles, `vt52`, `set_tabstop_width`, `titleset`, top-level `move`, and the width-test probes) accept an optional trailing `-console <consolespec>` pair, parsed manually for performance by `punk::console::internal::opt_console_out`/`opt_console_channels` (`_var` variants for procs whose args-tail also carries row/col/data triples). Each carries a documentation-only PUNKARGS definition that includes the shared `::punk::console::argdoc::console_emit_opts` fragment via `punk::args::resolved_def`; keep manual parsing and PUNKARGS synchronized. New emit procs must follow this pattern. Tests live in `src/tests/modules/punk/console/testsuites/console/emitconsole.test`.
- punk::console terminal-property facts (is_vt52, tabwidth, cell_size, last_da1_result, grapheme_cluster_support, check::has_bug_*) are per-console: read/write them via `punk::console::console_fact_get`/`console_fact_set`, keyed by canonical {in out} channel pair. The store is tsv-backed (G-007) so all threads read the same values: the process-default console `{stdin stdout}` keeps the legacy namespace variables (`::punk::console::is_vt52`, `tabwidth`, ...) as its authoritative local storage with write traces mirroring into tsv `punk_console_facts` (so existing external readers and direct writers keep working); non-default consoles store facts only in tsv with an owner-qualified key. Do not bypass the helpers for non-default consoles; use `console_fact_clear` (not direct store manipulation) to reset facts in tests. `ansi_wanted`/`colour_disabled` (string-generation gates), `ansi_available` and raw-mode state are deliberately process-global (rationale documented at the fact store in the module). Tests live in `src/tests/modules/punk/console/testsuites/console/consolefacts.test`.
- punk::console has a console ownership registry (G-007): `console_owner_register`/`console_owner_get`/`console_owner_forget`, tsv `punk_console_owners` keyed by canonical {in out} pair. Ownership is captured when an opunk::console instance is anchored (via `::opunk::console::lifecycle_callback`, wired by `ensure_object_integration`) and by `default_console`; an unregistered console reads as "operate locally". For `{stdin stdout}` first registration wins and only the owner's forget releases the entry. Owner liveness is validated at consult time. The `dec_has_mode`/`ansi_has_mode` caches live in tsv `punk_console_modecache`.
- punk::console terminal queries are owner-routed (G-007 choke-point brokering): `internal::get_ansi_response_payload` consults `internal::console_route_owner` after spec resolution and forwards the whole call to the console-owning thread via synchronous `thread::send` when the caller is not the owner, so queueing, raw-mode cycling and cooperative reader handling execute in the owner's context and every query proc above the choke point inherits the routing. Routing applies to the default console `{stdin stdout}` only: non-std channel names are thread-local, so an {in out} pair spec names the calling thread's own console and always operates locally (unregistered/self-owned/dead-owner likewise - single-interp behaviour is unchanged). The synchronous send relies on the owner servicing events while the caller blocks (the repl does this while a codethread runs - same property as the repl-installed vt52/colour/mode aliases, which are unaffected because a call arriving in the owner resolves to owner==self). Tests live in `src/tests/modules/punk/console/testsuites/console/ownerrouting.test`.
- Use `punk::args::parse` with `@id` references in `argdoc` namespaces for public API procs.
- Private helpers go in `namespace eval private { ... }` blocks.
- Keep `namespace export` lists alphabetized.

153
src/modules/punk/console-999999.0a1.0.tm

@ -564,6 +564,30 @@ namespace eval punk::console {
set cinfo [punk::console::console_spec_resolve $inoutchannels]
set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]]
lassign $inoutchannels input output
#G-007 choke-point brokering: a query on a console owned by another thread runs in
#the owning thread - where the cooperative reader protocol (input_chunks_waiting),
#raw-mode arbitration and the authoritative settled can_respond live. Forwarding the
#whole call means the queueing, raw cycling and reader cooperation below all execute
#in the owner's context, and every query proc layered above this choke point
#inherits the routing. Routed before the local can_respond gate deliberately: a
#non-owner context's anchored view of the default console may be unsettled while
#the owner's is settled. The synchronous thread::send is safe because the owner
#services events while this thread blocks (the property the repl-installed vt52
#alias transport already relies on), and re-entry in the owner resolves to
#owner==self so the forward cannot ping-pong.
set route_owner [console_route_owner $inoutchannels]
if {$route_owner ne ""} {
try {
return [thread::send $route_owner [list ::punk::console::internal::get_ansi_response_payload -console $inoutchannels -expected_ms $expected -ignoreok $ignoreok -return $returntype -passthrough $passthrough $query $capturingendregex]]
} on error {errM erropts} {
#preserve the owner-side errorcode (where the transport propagates it) so
#callers can discriminate, e.g. {PUNK CONSOLE QUERY HOSTAGE_COOKED_READ}
#from the idle-reader guard
return -code error -errorcode [dict get $erropts -errorcode] "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM"
}
}
set governing_obj [dict get $cinfo object]
if {$governing_obj ne ""} {
set governing_cr [::opunk::Console::get.o_can_respond $governing_obj]
@ -590,6 +614,28 @@ namespace eval punk::console {
error "punk::console::get_ansi_response_payload input channel '$input' is closed or at eof - cannot receive terminal response"
}
#tcl 8.6 windows console idle-reader hostage guard (fail fast, no emission).
#With the console in cooked (line) mode and a readable handler armed on the input
#channel, the 8.6 channel driver has a blocking cooked-mode ReadConsole parked
#(arming is what posts it), and driver reads sample the console mode at issue time -
#the raw cycle below cannot rescue it. Any response would be swallowed by that read
#until the user presses Enter, then leak to the line reader as phantom input
#(~500ms timeout now, input corruption later). This is the idle-at-a-line-mode-prompt
#condition: the repl disarms its reader during command dispatch, so mid-command
#queries are unaffected, and raw mode reads cooperatively and is exempt. Typical
#trigger: a query from an after-script or worker thread firing while the shell sits
#at a line-mode prompt. Best-effort by design: a parked read can outlive a removed
#handler, so this catches the systematic case, not every conceivable one.
if {![tsv::get punk_console is_raw] && $input eq "stdin" && $::punk::console::has_twapi} {
set guard_conf [chan configure $input]
if {![dict exists $guard_conf -inputmode] && ![dict exists $guard_conf -mode]
&& [chan event $input readable] ne ""
&& ![catch {twapi::get_console_handle stdin}]} {
return -code error -errorcode [list PUNK CONSOLE QUERY HOSTAGE_COOKED_READ]\
"punk::console::get_ansi_response_payload refusing to emit query on '$input': tcl 8.6 console is in cooked (line) mode with an armed readable handler - the driver's parked cooked read would swallow the response until Enter (query issued from an idle line-mode prompt?). Use raw mode, or issue queries while the reader is disarmed (e.g. during repl command evaluation)."
}
}
#chunks from input that need to be handled by readers
upvar ::punk::console::input_chunks_waiting input_chunks_waiting
@ -684,6 +730,16 @@ namespace eval punk::console {
#For mintty-without-winpty (terminal presenting pipes) the twapi raw cycle was already a
#no-op via its get_console_handle early-return, so nothing is lost by skipping.
set input_is_console_or_tty [expr {[dict exists $previous_input_state -inputmode] || [dict exists $previous_input_state -mode]}]
if {!$input_is_console_or_tty && $input eq "stdin" && $::punk::console::has_twapi} {
#Tcl 8.6 windows console channels have no -inputmode configure key, so the
#dict test alone misclassifies a real 8.6 console as a pipe: the raw cycle
#is then skipped and the query response sits in the cooked line buffer until
#Enter - timeout here plus the response leaking to the line reader as phantom
#input. A twapi console handle for stdin is definitive. Channels other than
#stdin stay excluded - the guard's purpose (pipe probes must not flip the
#process console) is unchanged.
set input_is_console_or_tty [expr {![catch {twapi::get_console_handle stdin}]}]
}
if {![tsv::get punk_console is_raw]} {
set was_raw 0
if {$input_is_console_or_tty} {
@ -1988,6 +2044,16 @@ namespace eval punk::console {
#pending keystroke with a probe read here.
return 0
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key, so the dict test above
#misses them. Beyond the pending-keystroke concern, the probe read is fatal here:
#a read on a drained 8.6 console channel makes the channel driver park a blocking
#cooked-mode ReadConsole that cannot be cancelled by a later raw-mode flip - it
#swallows a terminal query response emitted afterwards (get_ansi_response_payload
#calls this guard immediately before its raw cycle) and only completes on Enter.
return 0
}
#pipe-like channel - probe without blocking to force eof detection
if {[catch {
set prior_blocking [dict get $conf -blocking]
@ -2036,6 +2102,14 @@ namespace eval punk::console {
#tty on unix-like platforms
return 1
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key - a twapi console
#handle for stdin is definitive: process stdin is the real console. Terminals
#presenting channels as pipes (mintty without winpty) have no console handle
#and fall through to the env heuristics below.
return 1
}
if {[input_at_eof $input]} {
return 0
}
@ -2405,6 +2479,37 @@ namespace eval punk::console {
set owner [punk::console::console_owner_get {stdin stdout}]
return [expr {$owner eq "" || $owner eq [thread::id]}]
}
punk::args::define {
@id -id ::punk::console::internal::console_route_owner
@cmd -name punk::console::internal::console_route_owner -summary\
"Owning thread a console operation must be brokered to, or empty string to operate locally."\
-help\
"Routing consults the ownership registry for the process-default console
{stdin stdout} only: std channel names are process-wide, so any context can
refer to the shared console, but exactly one context owns its reader and
raw-mode arbitration. Non-std channel names are thread-local - an {in out}
pair spec names channels of the calling thread, making that thread the
console's local context - so non-default pairs always operate locally
(a registry entry for such a pair qualifies the shared fact store; it is
not a routing target). A console anchored by code-interp/worker code is
therefore operated on directly by its anchoring context, with no round-trip.
Returns empty string when the caller is the owner or no live owner is
registered."
@values -min 1 -max 1
inoutchannels -type list -help\
"Canonical {in out} channel pair."
}
proc console_route_owner {inoutchannels} {
#see PUNKARGS id ::punk::console::internal::console_route_owner
if {[lindex $inoutchannels 0] ne "stdin" || [lindex $inoutchannels 1] ne "stdout"} {
return ""
}
set owner [punk::console::console_owner_get {stdin stdout}]
if {$owner eq "" || $owner eq [thread::id]} {
return ""
}
return $owner
}
punk::args::define {
@id -id ::punk::console::internal::console_share_qualifier
@cmd -name punk::console::internal::console_share_qualifier -summary\
@ -2809,7 +2914,14 @@ namespace eval punk::console {
proc ensure_object_integration {} {
variable object_integration_done
if {$object_integration_done} {
return
return 1
}
if {![namespace exists ::opunk::console]} {
#opunk::console not loaded - nothing to integrate with yet. In-module callers only
#reach here after a successful 'package require opunk::console'; external/manual
#callers may call speculatively. Deliberately does not set object_integration_done,
#so a later call after opunk::console loads performs the wiring.
return 0
}
set ::opunk::console::waiting_chunks_arrayvar ::punk::console::input_chunks_waiting
if {$::opunk::console::size_query_provider eq ""} {
@ -2820,8 +2932,27 @@ namespace eval punk::console {
#consult-time liveness validation still cover the default console there)
if {[info exists ::opunk::console::lifecycle_callback] && $::opunk::console::lifecycle_callback eq ""} {
set ::opunk::console::lifecycle_callback [list ::punk::console::internal::object_console_lifecycle]
#catch-up registration: anchors created in this interp before the callback was
#wired never fired a 'created' event, so their consoles have no recorded owner
#(seen when user code does 'package require opunk::console; opunk::console::create ...'
#before any punk::console object operation triggers this wiring). Anchors are
#per-interp/per-thread, so the anchoring context is this thread - register it.
#Only fills empty entries: an existing live registration is newer information than
#these pre-wiring anchors (and for the default console first-registration wins).
foreach v [info vars ::opunk::console::instances::*] {
if {![info exists $v]} {
continue
}
if {[catch {::opunk::Console::channels [set $v]} channels]} {
continue
}
if {[console_owner_get $channels] eq ""} {
console_owner_register $channels
}
}
}
set object_integration_done 1
return 1
}
lappend PUNKARGS [list {
@ -2892,12 +3023,19 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::move 2000 2000]
#flush before querying: the position query may execute in the console-owning
#thread (G-007 routing), whose flush acts on its own channel instance for the same
#OS handle - an unflushed move here would leave the terminal reporting the
#unmoved cursor position (seen as 'columns 1' on tcl 8.6 where the -winsize
#shortcut is unavailable and this mechanism actually runs)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::move $start_row $start_col][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::move $start_row $start_col]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -2931,12 +3069,16 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::cursor_save_dec][punk::ansi::move 2000 2000]
#flush before querying - see get_size_using_cursormove (G-007 routing: the query
#may flush a different channel instance in the console-owning thread)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::cursor_restore][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::cursor_restore_dec]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -4186,6 +4328,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before each cursor query: the query may execute in the console-owning thread
#(G-007 routing) whose flush acts on its own channel instance - unflushed emissions
#here would be measured as if they never happened (see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -4207,6 +4353,7 @@ namespace eval punk::console {
} errM]} {
puts stderr "test_char_width couldn't emit this string - \nerror: $errM"
}
flush $out ;#the test emission must reach the terminal before the (possibly routed) query measures it
set response [punk::console::get_cursor_pos $inoutchannels]
lassign [split $response ";"] _row2 col2
@ -4261,6 +4408,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before the cursor query - the alt-screen/move/erase emissions above must reach
#the terminal first (the query may flush a different channel instance in the
#console-owning thread under G-007 routing - see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]

8
src/modules/punk/console-buildversion.txt

@ -1,6 +1,12 @@
0.6.0
0.7.1
#First line must be a semantic version number
#all other lines are ignored.
#0.7.1 - new idle-reader hostage guard in get_ansi_response_payload: on a tcl 8.6 windows console in cooked (line) mode with a readable handler armed on stdin (the idle-at-a-line-mode-prompt condition - e.g. a query fired from an after-script or worker thread while the shell waits for input), the query now fails fast with errorcode {PUNK CONSOLE QUERY HOSTAGE_COOKED_READ} and emits nothing, instead of timing out (~500ms) and having the response swallowed by the driver's parked cooked ReadConsole until Enter then leaking to the line reader as phantom input. Mid-command queries (repl reader disarmed) and raw mode are unaffected; best-effort - a parked read can outlive a removed handler, so the guard catches the systematic case only
#0.7.1 - compound emit-then-query operations flush their emissions before querying: get_size_using_cursormove/get_size_using_cursorrestore (the far-corner move), test_char_width (positioning and the measured test emission) and test_string_cursor (alt-screen/move/erase). Under G-007 routing the position query may execute in the console-owning thread, whose flush acts on its own channel instance for the same OS handle - the caller's unflushed emissions then reach the terminal after the query measures, e.g. get_size returning 'columns 1' on tcl 8.6 where the -winsize shortcut is unavailable and the ANSI mechanism actually runs (pre-routing this was masked because emit and query shared one channel instance, so the query's flush pushed the emissions too). Error paths flush their cursor-restore emissions likewise.
#0.7.1 - ensure_object_integration hardening: returns 0 as a graceful no-op (without latching object_integration_done) when opunk::console is not loaded, instead of erroring into the nonexistent namespace; and on wiring the lifecycle callback it retro-registers ownership for anchors created in this interp before the wiring (previously 'package require opunk::console; opunk::console::create ...' with no intervening punk::console object operation left the console without a registered owner - fact-store share qualifiers then fell back to the calling thread and other threads could not address that console's facts). Retro-registration fills empty registry entries only; existing live registrations are preserved.
#0.7.1 - fix Tcl 8.6 windows console misdetection (one root cause, three sites): 8.6 console channels expose no -inputmode configure key, so guards that test for -inputmode/-mode classified a real 8.6 console as a pipe. A twapi console handle for stdin is now treated as definitive at all three sites (stdin only - pipe probes still never flip the process console; mintty-as-pipes has no console handle and falls through to the env heuristics). Site 1: get_ansi_response_payload's raw-cycling gate (0.1.5) skipped the raw cycle for 8.6 line-mode queries. Site 2: is_input_console_or_tty false negative made settle_can_respond's layer-2 heuristic settle a real 8.6 console as unable to respond. Site 3 (the line-mode query killer, introduced 0.1.2): input_at_eof took the pipe branch and performed its probe read on the drained console channel - a read on a drained 8.6 console makes the channel driver park a blocking cooked-mode ReadConsole that a later raw flip cannot cancel (verified empirically against clean tclkits 8.6.13/8.6.17: driver reads sample the console mode when issued, not when data arrives), and since get_ansi_response_payload calls input_at_eof immediately before its raw cycle, every 8.6 line-mode query's response was swallowed by that parked read until Enter (~500ms timeout + response leaking to the line reader as phantom input; also the cause of increased ANSI artifacts in outputs like 'help env' once 0.7.0 brokering made code-interp queries actually reach the terminal).
#0.7.0 - G-007 slice 2 (choke-point brokering): a terminal query (internal::get_ansi_response_payload and every query proc layered above it) on the process-default console {stdin stdout} issued by a thread that is not the registered owner is forwarded whole to the owning thread via synchronous thread::send, so queueing, raw-mode cycling, cooperative reader handling (input_chunks_waiting) and can_respond gating all execute in the owner's context; owner-side errors propagate to the caller with routing context
#0.7.0 - new internal::console_route_owner decides the routing: default console pair only (non-std channel names are thread-local, so an {in out} pair spec always names the calling thread's own console and operates locally - a console anchored by code-interp/worker code round-trips nowhere); unregistered, owner==caller, and dead-owner (liveness-validated) cases operate locally, preserving single-interp behaviour exactly
#0.6.0 - G-007 slice 1 (facts + ownership): per-console facts store is tsv-backed (shared array punk_console_facts) so all threads of a punk session read the same values; the default console {stdin stdout} keeps the legacy namespace variables as its authoritative local storage with write traces mirroring every write (including direct variable writes) into tsv; non-default consoles store facts only in tsv, keyed with an owner qualifier because non-std channel names are thread-local
#0.6.0 - new console ownership registry: console_owner_register/console_owner_get/console_owner_forget record which thread owns a console (tsv punk_console_owners, keyed by canonical {in out} pair); ownership is captured at anchor time via the new opunk::console lifecycle_callback (wired by ensure_object_integration) and by default_console; consult-time liveness validation clears entries for exited threads; for {stdin stdout} first registration wins and only the owner's forget releases the entry
#0.6.0 - console_fact_set from a thread that does not own the default console forwards the write to the owning thread via thread::send (vt52-alias transport precedent) with a direct tsv fallback; console_fact_get from a non-owner reads the tsv mirror

49
src/modules/punk/repl-999999.0a1.0.tm

@ -1635,6 +1635,7 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
#note -inputmode not available in Tcl 8.6 for chan configure!
#According to DKF - -buffering option doesn't affect input channels
set rawmode 0
set waiting_needs_reader 0 ;#set when the 8.6-console waiting-chunks path skips its read on a drained channel - the readable handler must be armed (not the after-idle reinvoke) or the pending partial line could never complete
set original_input_conf [chan configure $inputchan] ;#whether repl is in line or raw mode - we restore the inputchan (stdin) state
if {[dict exists $original_input_conf -inputmode]} {
@ -1693,9 +1694,46 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set chunk [read $inputchan]
set hostage_noread 0
if {"windows" eq $::tcl_platform(platform)
&& ![dict exists $original_input_conf -inputmode]
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]} {
#tcl 8.6 windows console channel (no -inputmode configure key, real console
#handle). On the 8.6 console driver a read on a *drained* channel parks a
#blocking cooked-mode ReadConsole that a later raw-mode flip cannot cancel:
#it swallows terminal query responses until Enter (see punk::console 0.7.1
#buildversion notes). This waiting-chunks path is normally entered via
#'after idle' with the channel drained - only consume what is already in the
#Tcl channel buffer (sized read - no driver probe); when nothing is buffered,
#process the stashed complete lines without reading and arm the readable
#handler for the rest.
set avail [chan pending input $inputchan]
if {$avail > 0} {
set chunk [read $inputchan $avail]
} else {
set chunk ""
set hostage_noread 1
set waiting_needs_reader 1
}
} else {
set chunk [read $inputchan]
}
set chunksize [string length $chunk]
if {$chunksize > 0} {
if {$hostage_noread} {
#nothing in the Tcl buffer and we must not probe the drained driver.
#Process the complete stashed lines now; a remaining partial line can only
#progress via more user input, which the armed readable handler delivers.
if {[llength $stdinlines]} {
punk::repl::repl_handler_restorechannel_if_not_eof $inputchan $original_input_conf
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
}
if {$waitingchunk ne ""} {
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set input_chunks_waiting($inputchan) [list]
}
} elseif {$chunksize > 0} {
if {[string index $chunk end] eq "\n"} {
lappend stdinlines $waitingchunk[string range $chunk 0 end-1]
#punk::console::cursorsave_move_emitblock_return 30 30 "repl_handler num_stdinlines [llength $stdinlines] chunk:$yellow[ansistring VIEW -lf 1 $chunk][a] fblocked:[fblocked $inputchan] pending:[chan pending input stdin]"
@ -1864,7 +1902,12 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
##################################################################################
#Re-enable channel read handler only if no waiting chunks - must process in order
##################################################################################
if {![llength $input_chunks_waiting($inputchan)]} {
if {![llength $input_chunks_waiting($inputchan)] || $waiting_needs_reader} {
#waiting_needs_reader: the 8.6-console waiting-chunks path found nothing in the
#Tcl channel buffer and must not probe the drained driver - the waiting data is
#an incomplete line that can only progress when the user types more, so arm the
#reader (the driver listening for input is its job here) instead of an after-idle
#reinvoke that could never make progress.
chan event $inputchan readable [list ::repl::repl_handler $inputchan $readmore $prompt_config]
} else {
#review

3
src/modules/punk/repl-buildversion.txt

@ -1,6 +1,7 @@
0.2.1
0.2.2
#First line must be a semantic version number
#all other lines are ignored.
#0.2.2 - repl_handler line-mode waiting-chunks path no longer performs its opportunistic unsized read on tcl 8.6 windows console channels (no -inputmode key, real twapi console handle): the path is entered via 'after idle' with the channel usually drained, and on the 8.6 console driver a drained read parks a blocking cooked-mode ReadConsole that a later raw flip cannot cancel - after typed-ahead was stashed during a terminal-query raw window, that parked read swallowed every subsequent query response in the session until Enter (companion to the punk::console 0.7.1 three-site console-misdetection fix). On such consoles the path now consumes only data already in the Tcl channel buffer (chan pending input + sized read - no driver probe); with nothing buffered it processes the stashed complete lines directly and arms the readable handler for any remaining partial line (replacing an after-idle reinvoke that could never progress). Behaviour on tcl 9/8.7 (-inputmode consoles) and in raw mode is unchanged.
#0.2.1 - call-site update for punk::console 0.6.0 tsv array rename: console -> punk_console (is_raw); no behaviour change
#0.2.0 - codethread init_script now propagates package prefer via %packageprefer% scriptmap entry
#0.2.0 - code interp (punk/0 repltype) now uses punk::lib::interp_sync_package_paths -libunknown 1 instead of manual epoch-only copy, propagating tm list, auto_path, package prefer, and libunknown to the code interp

214
src/modules/punkboot/utils-999999.0a1.0.tm

@ -0,0 +1,214 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use 'pmix make' or src/make.tcl to update from <pkg>-buildversion.txt
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2023
#
# @@ Meta Begin
# Application punkboot::utils 999999.0a1.0
# Meta platform tcl
# Meta license BSD
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
##e.g package require frobz
package require Tcl 8.6-
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
namespace eval punkboot::utils {
variable version 0.1.0
namespace export parse_punkproject_version read_punkproject_version read_changelog_latest_version vcs_dirty_warnings
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::parse_punkproject_version
@cmd -name "::punkboot::utils::parse_punkproject_version"\
-summary\
"Parse the [project] version from TOML content string"\
-help\
"Scans TOML content for the [project] section and returns
the value of the version key as a string. Returns an empty
string if the section or key is not found."
@leaders
content -type string -optional 0 -help\
"TOML content string to parse"
}]
}
proc parse_punkproject_version {content} {
set in_project 0
foreach line [split $content \n] {
set trimmed [string trim $line]
if {[string index $trimmed 0] eq {[} && [string index $trimmed end] eq {]}} {
set in_project [expr {$trimmed eq "\[project\]"}]
continue
}
if {$in_project && [regexp {^version\s*=\s*"([^"]+)"} $trimmed _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_punkproject_version
@cmd -name "::punkboot::utils::read_punkproject_version"\
-summary\
"Read the [project] version from a TOML file"\
-help\
"Reads the file at tomlfile and returns the [project]
version value as a string. Returns an empty string if the
file does not exist or the version is not found."
@leaders
tomlfile -type string -optional 0 -help\
"Path to the TOML file to read"
}]
}
proc read_punkproject_version {tomlfile} {
if {![file exists $tomlfile]} {return ""}
set fh [open $tomlfile r]
try {
set content [read $fh]
} finally {
close $fh
}
return [::punkboot::utils::parse_punkproject_version $content]
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_changelog_latest_version
@cmd -name "::punkboot::utils::read_changelog_latest_version"\
-summary\
"Read the latest ## [X.Y.Z] version header from a changelog file"\
-help\
"Scans the changelog file from the top and returns the
version string from the first '## [X.Y.Z]' header line.
Returns an empty string if the file does not exist or no
matching header is found."
@leaders
changelogfile -type string -optional 0 -help\
"Path to the changelog file to read"
}]
}
proc read_changelog_latest_version {changelogfile} {
if {![file exists $changelogfile]} {return ""}
set fh [open $changelogfile r]
try {
set content [read $fh]
} finally {
close $fh
}
foreach line [split $content \n] {
if {[regexp {^##\s*\[([0-9][^\]]*)\]} $line _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::vcs_dirty_warnings
@cmd -name "::punkboot::utils::vcs_dirty_warnings"\
-summary\
"Warning lines for uncommitted VCS changes above a path"\
-help\
"Walks up from path to the nearest fossil and/or git root and
returns warning lines describing uncommitted changes.
Artifacts built from a dirty source tree have no committed
provenance - callers such as make.tcl vendorupdate use this
to flag source projects that should be committed first.
checkedrootsvar names a dict variable in the caller's scope
used to report each VCS root at most once across calls.
label, if supplied, is prefixed into each warning to name
the calling operation (e.g. 'vendorupdate').
Returns an empty list if the path is clean, unversioned,
missing, already reported, or state cannot be determined."
@leaders
path -type string -optional 0 -help\
"Path to check; the nearest enclosing fossil/git root is examined"
checkedrootsvar -type string -optional 0 -help\
"Name of a dict variable in the caller's scope for dedupe across calls"
label -type string -optional 1 -default "" -help\
"Operation name prefixed into warnings"
}]
}
proc vcs_dirty_warnings {path checkedrootsvar {label ""}} {
upvar 1 $checkedrootsvar checkedroots
if {![info exists checkedroots]} {set checkedroots [dict create]}
if {$label ne ""} {set label "$label "}
set warnings [list]
set dir [file normalize $path]
if {![file isdirectory $dir]} {
return $warnings ;#missing path - not this proc's business to report
}
set fossilroot ""
set gitroot ""
while {1} {
if {$fossilroot eq "" && ([file exists [file join $dir .fslckout]] || [file exists [file join $dir _FOSSIL_]])} {
set fossilroot $dir
}
if {$gitroot eq "" && [file exists [file join $dir .git]]} {
set gitroot $dir
}
if {$fossilroot ne "" || $gitroot ne ""} {
break ;#report at the nearest VCS root(s); a dual git+fossil root is caught in one pass
}
set parent [file dirname $dir]
if {$parent eq $dir} { break }
set dir $parent
}
if {$fossilroot ne "" && ![dict exists $checkedroots fossil,$fossilroot] && [llength [auto_execok fossil]]} {
dict set checkedroots fossil,$fossilroot 1
#fossil changes must run from within the checkout
set original_cwd [pwd]
if {[catch {
cd $fossilroot
set fchanges [string trim [exec {*}[auto_execok fossil] changes]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine fossil state of source project at $fossilroot ($errM)"
} elseif {$fchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $fossilroot has uncommitted fossil changes ([llength [split $fchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
cd $original_cwd
}
if {$gitroot ne "" && ![dict exists $checkedroots git,$gitroot] && [llength [auto_execok git]]} {
dict set checkedroots git,$gitroot 1
if {[catch {
set gchanges [string trim [exec {*}[auto_execok git] -C $gitroot status --porcelain]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine git state of source project at $gitroot ($errM)"
} elseif {$gchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $gitroot has uncommitted git changes ([llength [split $gchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
}
return $warnings
}
}
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punkboot::utils ::punkboot::utils::argdoc
}
## Ready
package provide punkboot::utils [tcl::namespace::eval punkboot::utils {
variable version
#- this version number, exactly 999999.0a1.0, is a literal used in src module folders
#- we refer to this sometimes as the magic version number
set version 999999.0a1.0
}]
return

5
src/modules/punkboot/utils-buildversion.txt

@ -0,0 +1,5 @@
0.1.1
#First line must be a semantic version number
#all other lines are ignored.
#0.1.1 - added vcs_dirty_warnings (dirty fossil/git checkout provenance warnings; used by make.tcl vendorupdate, shared with bootsupport update per goal G-026)
#0.1.0 - initial: parse_punkproject_version, read_punkproject_version, read_changelog_latest_version extracted from make.tcl inline procs

40
src/modules/shellfilter-999999.0a1.0.tm

@ -2849,9 +2849,19 @@ namespace eval shellfilter {
set id_out [shellfilter::stack::add $outchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_out -pipechan $outpipechan]]
set id_err [shellfilter::stack::add $errchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_err -pipechan $errpipechan]]
#From here to the stack::remove calls the caller's channels are diverted:
#any error escaping this region would leave stdout/stderr broken for the rest of the
#process with all subsequent output silently lost - hence the try/finally below, and
#catch-guards on decorative logging (stack-status table rendering runs punk::ansi/textblock
#code that can fail e.g when mismatched module snapshots are loaded - a rendering failure
#must degrade to a log note, never abort the run).
log::critical {
::shellfilter::log::write $runtag "[punk::ansi::ansistrip [shellfilter::stack status]]\nchan names:[chan names]"
if {[catch {punk::ansi::ansistrip [shellfilter::stack status]} stackstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $stackstatus)"
}
::shellfilter::log::write $runtag "$stackstatus\nchan names:[chan names]"
}
try {
# need to use os level channel handle for stdin - try named pipes (or even sockets) instead of fifo2 for this
# If non os-level channel - the command can't be run with the redirection
# stderr/stdout can be run with non-os handles in the call -
@ -2919,22 +2929,28 @@ namespace eval shellfilter {
#----------------------------------------------------------------------------------------------
#todo - change to log::debug
log::critical {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
if {[catch {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
}
} errstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $errstatus)"
}
::shellfilter::log::write $runtag "shellfilter::stack status after execution: \n$stackstatus\nchan:names [chan names]"
}
#----------------------------------------------------------------------------------------------
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
} finally {
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack - must happen even if the body above errored,
#or the caller's stdout/stderr stay diverted and all subsequent process output is silently lost.
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
}
log::info {::shellfilter::log::write $runtag " return '$exitinfo'"}

5
src/modules/shellfilter-buildversion.txt

@ -1,3 +1,4 @@
0.2.2
0.2.3
#First line must be a semantic version number
#all other lines are ignored.
#all other lines are ignored.
#0.2.3 - shellfilter::run: try/finally guarantees tee-stack removal (stdout/stderr restoration) even when the body errors; decorative stack-status table rendering in log::critical blocks is catch-guarded so a rendering failure (e.g mismatched module snapshots - stale textblock vs new punk::ansi sgr_merge_singles options) degrades to a log note instead of aborting the run and leaving the process's stdout/stderr diverted (silent-output-loss failure mode found via tomlish runtests 2026-07-06)

419
src/modules/shellrun-999999.0a1.0.tm

@ -5,6 +5,7 @@
package require shellfilter
package require punk::ansi
package require punk::args
#NOTE: the run,runout,runerr,runx commands only produce an error if the command didn't run.
# - If it did run, but there was a non-zero exitcode it is up to the application to check that.
@ -79,76 +80,45 @@ namespace eval shellrun {
#maintenance: similar used in punk::ns & punk::winrun
#todo - take runopts + aliases as args
#longopts must be passed as a single item ie --timeout=100 not --timeout 100
proc get_run_opts {arglist} {
if {[catch {
set callerinfo [info level -1]
} errM]} {
set caller ""
} else {
set caller [lindex $callerinfo 0]
}
#we provide -nonewline even for 'run' even though run doesn't deliver stderr or stdout to the tcl return value
#This is for compatibility with other runX commands, and the difference is also visible when calling from repl.
set known_runopts [list "-echo" "-e" "-nonewline" "-n" "-tcl" "-debug"]
set known_longopts [list "--timeout"]
set known_longopts_msg ""
foreach lng $known_longopts {
append known_longopts_msg "${lng}=val "
}
set aliases [list "-e" "-echo" "-echo" "-echo" "-n" "-nonewline" "-nonewline" "-nonewline" "-tcl" "-tcl" "-debug" "-debug"] ;#include map to self
set runopts [list]
set runoptslong [list]
set cmdargs [list]
set idx_first_cmdarg [lsearch -not $arglist "-*"]
set allopts [lrange $arglist 0 $idx_first_cmdarg-1]
set cmdargs [lrange $arglist $idx_first_cmdarg end]
foreach o $allopts {
if {[string match --* $o]} {
lassign [split $o =] flagpart valpart
if {$valpart eq ""} {
error "$caller: longopt $o seems to be missing a value - must be of form --option=value"
}
if {$flagpart ni $known_longopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runoptslong $flagpart $valpart
} else {
if {$o ni $known_runopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runopts [dict get $aliases $o]
}
}
return [list runopts $runopts runoptslong $runoptslong cmdargs $cmdargs]
}
#todo - investigate cause of punk86 run hanging sometimes. An 'after 500' before exit in the called script fixes the issue. punk87 doesn't seem to be affected.
lappend PUNKARGS [list {
@id -id ::shellrun::run
@cmd -name "shellrun::run"\
-summary "Run an external command, streaming stdout/stderr to the console in real-time"\
-help\
"Run an external command and return the shellfilter exitinfo dict.
stdout and stderr are written to the console as they arrive;
nothing is captured for the return value. The exitcode is
part of the returned dict — a non-zero exitcode is NOT raised
as a Tcl error, the caller must check it explicitly.
Use -tcl to run a Tcl script/command instead of an external
process; in that case the returned dict carries a 'result'
key (or error/errorCode/errorInfo on failure) instead of an
exitcode."
@leaders -min 0 -max 0
@opts
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-nonewline -type none -help\
"Accepted for compatibility with runout/runerr/runx. Has no
effect on run because run does not capture or return stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc run {args} {
#set_last_run_display [list]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set runoptslong [dict get $splitargs runoptslong]
#set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::run]
lassign [dict values $argd] leaders opts values received
@ -206,11 +176,25 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runconsole
@cmd -name "shellrun::runconsole"\
-summary "Run a command via exec with stdout/stderr directed to the console, mimicking Tcl's unknown handler"\
-help\
"Resolve cmdname via auto_execok and exec it with stdout
and stderr redirected to the active console. The result is
returned via uplevel with the exec result/options dict,
mirroring the way Tcl's unknown proc handles external
commands.
Unlike run, this uses exec semantics — there is no
exitcode in the return value and stdout/stderr are not
captured separately."
@leaders -min 0 -max 0
@opts
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the command to resolve via auto_execok and run."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed to the resolved command."
}]
#run in the way tcl unknown does - but without regard to auto_noexec
proc runconsole {args} {
@ -256,16 +240,42 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runout
@cmd -name "shellrun::runout"\
-summary "Run an external command and return its stdout"\
-help\
"Run an external command, capture stdout and stderr, and
return stdout as the result. stderr and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stdout.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runout {args} {
set argd [punk::args::parse $args withid ::shellrun::runout]
@ -284,11 +294,6 @@ namespace eval shellrun {
set runerr ""
set RST [a]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
#puts stdout "RUNOUT cmdargs: $cmdargs"
#todo add -data boolean and -data lastwrite to -settings with default being -data all
@ -422,16 +427,42 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runerr
@cmd -name "shellrun::runerr"\
-summary "Run an external command and return its stderr"\
-help\
"Run an external command, capture stdout and stderr, and
return stderr as the result. stdout and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stderr.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runerr {args} {
set argd [punk::args::parse $args withid ::shellrun::runerr]
@ -449,10 +480,6 @@ namespace eval shellrun {
set runout ""
set runerr ""
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
@ -567,6 +594,43 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runx
@cmd -name "shellrun::runx"\
-summary "Run an external command and return a dict of stdout, stderr, and exitcode"\
-help\
"Run an external command, capture stdout and stderr, and
return a dict containing stdout, stderr, and exitcode (or
result/error/errorCode/errorInfo for -tcl calls).
No console output is produced unless -echo is given. Use
-nonewline to strip trailing newlines from captured stdout
and stderr before returning.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runx {args} {
#set_last_run_display [list]
variable runout
@ -574,11 +638,10 @@ namespace eval shellrun {
set runout ""
set runerr ""
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::runx]
lassign [dict values $argd] leaders opts values received
if {"-nonewline" in $runopts} {
if {[dict exists $received "-nonewline"]} {
set nonewline 1
} else {
set nonewline 0
@ -586,7 +649,7 @@ namespace eval shellrun {
#shellfilter::stack::remove stdout $::repl::id_outstack
if {"-echo" in $runopts} {
if {[dict exists $received "-echo"]} {
#float to ensure repl transform doesn't interfere with the output data
set stderr_stackid [shellfilter::stack::add stderr tee_to_var -action float -settings {-varname ::shellrun::runerr}]
set stdout_stackid [shellfilter::stack::add stdout tee_to_var -action float -settings {-varname ::shellrun::runout}]
@ -601,10 +664,23 @@ namespace eval shellrun {
set stdout_stackid [shellfilter::stack::add stdout var -action float-locked -junction 1 -settings {-varname ::shellrun::runout}]
}
set callopts ""
if {"-tcl" in $runopts} {
append callopts " -tclscript 1"
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
}
if {[dict exists $received "-debug"]} {
dict set callopts -debug 1
}
if {[dict exists $received --timeout]} {
dict set callopts -timeout [dict get $opts --timeout]
}
set cmdname [dict get $values cmdname]
if {[dict exists $received cmdarg]} {
set cmdarglist [dict get $values cmdarg]
} else {
set cmdarglist {}
}
set cmdargs [concat $cmdname $cmdarglist]
#set exitinfo [shellfilter::run $cmdargs -teehandle punksh -inbuffering none -outbuffering none -stdinhandler ::repl::repl_handler]
set exitinfo [shellfilter::run $cmdargs {*}$callopts -teehandle punksh -inbuffering none -outbuffering none]
@ -616,7 +692,7 @@ namespace eval shellrun {
flush stdout
if {[dict exists $exitinfo error]} {
if {"-tcl" in $runopts} {
if {[dict exists $received "-tcl"]} {
} else {
#todo - check errorInfo makes sense.. return -code? tailcall?
@ -826,30 +902,161 @@ namespace eval shellrun {
return $exitinfo
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_run
@cmd -name "shellrun::sh_run"\
-summary "Run a command line via sh -c, streaming stdout/stderr to the console"\
-help\
"Wrapper around shellrun::run that invokes the command line
via sh -c. The entire command line after options is passed
as a single argument to sh -c.
Accepts the same options as shellrun::run except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-nonewline -type none -help\
"Accepted for compatibility. Has no effect on run-based
commands because run does not capture output."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_run {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_run]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
#e.g sh -c "ls -l *"
#we pass cmdargs to sh -c as a list, not individually
tailcall shellrun::run {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runout
@cmd -name "shellrun::sh_runout"\
-summary "Run a command line via sh -c and return its stdout"\
-help\
"Wrapper around shellrun::runout that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runout except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runout {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runout]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runout {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runerr
@cmd -name "shellrun::sh_runerr"\
-summary "Run a command line via sh -c and return its stderr"\
-help\
"Wrapper around shellrun::runerr that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runerr except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runerr {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runerr]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runerr {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runx
@cmd -name "shellrun::sh_runx"\
-summary "Run a command line via sh -c and return a dict of stdout, stderr, and exitcode"\
-help\
"Wrapper around shellrun::runx that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runx except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runx {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runx]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runx {*}$runopts sh -c $cmdargs
}
}

13
src/modules/shellrun-buildversion.txt

@ -1,3 +1,10 @@
0.1.2
#First line must be a semantic version number
#all other lines are ignored.
0.1.4
#First line must be a semantic version number
#all other lines are ignored.
#0.1.4 - added missing 'package require punk::args' (0.1.3 call sites use punk::args::parse; absence broke shellrun in bare interps e.g. the runtests testinterp)
#0.1.3 - migrated runx from get_run_opts to punk::args::parse (added PUNKARGS spec with @cmd + -help)
#0.1.3 - migrated sh_run/sh_runout/sh_runerr/sh_runx from get_run_opts to punk::args::parse (added PUNKARGS specs)
#0.1.3 - removed get_run_opts proc; all callers now use punk::args
#0.1.3 - expanded PUNKARGS documentation for run, runconsole, runout, runerr (added @cmd metadata + -help on opts/values)
#0.1.3 - runx now honours -debug and --timeout (previously accepted but silently ignored)
#0.1.3 - removed -tcl from sh_run/sh_runout/sh_runerr/sh_runx (was silently swallowed with no useful effect; now rejected with usage)

106
src/project_layouts/custom/_project/punk.basic/src/make.tcl

@ -22,7 +22,7 @@ namespace eval ::punkboot {
variable pkg_requirements [list]; variable pkg_missing [list];variable pkg_loaded [list]
variable non_help_flags [list -k]
variable help_flags [list -help --help /? -h]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate ]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate projectversion]
}
@ -1358,6 +1358,8 @@ proc ::punkboot::punkboot_gethelp {args} {
append h " - show module/library paths and any potentially problematic packages for running this script" \n
append h " $scriptname shell" \n
append h " - run the punk shell using bootsupport libraries." \n
append h " $scriptname projectversion" \n
append h " - advisory check: verify CHANGELOG.md matches punkproject.toml and warn if src/ has changes since the last project-version bump." \n \n
append h "" \n
if {[llength [dict get $pkg_availability missing]] || [llength [dict get $pkg_availability broken]]} {
set has_recommended 0
@ -1532,7 +1534,7 @@ if {[lsearch $::argv -k] >= 0} {
# - minor bump -> prompt user; backward-compat aliases *should* keep old bootsupport functional
# - patch bump -> warn and proceed (non-breaking)
# 'check' is exempt in all cases the warning is shown at the end of its output instead.
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check"} {
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check" && $::punkboot::command ne "projectversion"} {
set _stale $::punkboot::stale_bootsupport
set _have_major 0
set _have_minor 0
@ -1825,8 +1827,88 @@ if {$::punkboot::command eq "info"} {
exit 0
}
if {$::punkboot::command eq "projectversion"} {
package require punkboot::utils
set sep [string repeat - 75]
puts stdout $sep
puts stdout "project version check"
puts stdout $sep
set pp_file [file join $projectroot punkproject.toml]
set cl_file [file join $projectroot CHANGELOG.md]
set src_folder [file join $projectroot src]
set pp_version [::punkboot::utils::read_punkproject_version $pp_file]
set cl_version [::punkboot::utils::read_changelog_latest_version $cl_file]
puts stdout "punkproject.toml version : [expr {[string length $pp_version] ? $pp_version : "(not found)"}]"
puts stdout "CHANGELOG.md version : [expr {[string length $cl_version] ? $cl_version : "(not found)"}]"
# 1. Consistency: CHANGELOG latest header must match punkproject.toml version.
if {![string length $pp_version]} {
puts stderr "WARNING: could not read version from $pp_file"
} elseif {![string length $cl_version]} {
puts stderr "WARNING: no '## [X.Y.Z]' version header found in $cl_file"
} elseif {$pp_version ne $cl_version} {
puts stderr "WARNING: version mismatch punkproject.toml=$pp_version but CHANGELOG.md=$cl_version"
puts stderr " The latest '## [X.Y.Z]' header in CHANGELOG.md must match the version in punkproject.toml."
} else {
puts stdout "consistency: OK (versions match)"
}
# 2. Staleness: warn if src/ has git commits since the last commit
# touching punkproject.toml (version bump may be overdue).
# If punkproject.toml has an uncommitted version change, the bump is
# already in progress report that only a commit is needed.
# Best-effort silently skipped if git is unavailable or the repo
# is not under git revision control.
set pp_commit ""
if {![catch {exec git -C $projectroot log -1 --format=%H -- punkproject.toml} pp_raw]} {
set pp_commit [string trim $pp_raw]
}
if {[string length $pp_commit]} {
if {![catch {exec git -C $projectroot log --oneline ${pp_commit}..HEAD -- src/} src_raw]} {
set src_changes [string trim $src_raw]
if {[string length $src_changes]} {
set n [llength [split $src_changes \n]]
# Check whether a bump is already staged in the working tree
# (punkproject.toml modified but uncommitted). If the working-
# tree version differs from HEAD, the bump is done only a
# commit is needed.
set head_version ""
if {![catch {exec git -C $projectroot show HEAD:punkproject.toml} head_raw]} {
set head_version [::punkboot::utils::parse_punkproject_version $head_raw]
}
set bump_uncommitted [expr {[string length $head_version] && [string length $pp_version] && $pp_version ne $head_version}]
if {$bump_uncommitted} {
puts stderr "NOTE: $n commit(s) in src/ since last punkproject.toml commit, but punkproject.toml already bumped ($head_version -> $pp_version, uncommitted)."
puts stderr " Commit punkproject.toml (and CHANGELOG.md) to record the bump and clear this warning."
} else {
puts stderr "WARNING: $n commit(s) in src/ since last punkproject.toml change a project-version bump may be overdue."
puts stderr " Review the changes and bump punkproject.toml per the root AGENTS.md 'Project Versioning' section."
}
puts stderr " (showing first 5 commits below)"
set i 0
foreach line [split $src_changes \n] {
if {$i >= 5} break
puts stderr " $line"
incr i
}
} else {
puts stdout "staleness: OK (no src/ changes since last punkproject.toml commit)"
}
} else {
puts stdout "staleness: skipped (git query failed)"
}
} else {
puts stdout "staleness: skipped (punkproject.toml has no git history or git unavailable)"
}
puts stdout $sep
exit 0
}
if {$::punkboot::command eq "shell"} {
package require struct::list
@ -1890,10 +1972,20 @@ if {$::punkboot::command eq "vendorupdate"} {
#puts "-- [tcl::tm::list] --"
puts stdout "Updating vendor modules in src folder"
#dirty-checkout provenance warnings via punkboot::utils::vcs_dirty_warnings (loaded from bootsupport).
#Guarded require: vendorupdate must keep working even if the bootsupport punkboot::utils copy is
#missing or stale (it may be the very thing being repaired) - the check degrades to skipped.
set have_vcs_dirty_check [expr {![catch {package require punkboot::utils}] && [llength [info commands ::punkboot::utils::vcs_dirty_warnings]]}]
if {!$have_vcs_dirty_check} {
puts stderr "WARNING: vendorupdate dirty-checkout provenance check unavailable (punkboot::utils vcs_dirty_warnings not loadable from bootsupport) - continuing without it"
}
proc vendor_localupdate {projectroot} {
set local_modules [list]
set git_modules [list]
set fossil_modules [list]
set checked_source_paths [dict create] ;#skip repeat walk-ups for identical source paths
set checked_vcs_roots [dict create] ;#report each VCS root at most once per run
set sourcefolder $projectroot/src
#todo vendor/lib
set vendorlibfolders [glob -nocomplain -dir $sourcefolder -type d -tails vendorlib_tcl*]
@ -1939,6 +2031,16 @@ if {$::punkboot::command eq "vendorupdate"} {
} else {
set srclocation [file join $localpath $module_subpath]
}
#dirty-checkout provenance check - each VCS root reported at most once per run
if {$::have_vcs_dirty_check} {
set normsrclocation [file normalize $srclocation]
if {![dict exists $checked_source_paths $normsrclocation]} {
dict set checked_source_paths $normsrclocation 1
foreach w [::punkboot::utils::vcs_dirty_warnings $normsrclocation checked_vcs_roots vendorupdate] {
puts stderr $w
}
}
}
#puts stdout "$relpath $module $module_subpath $srclocation"
#todo - check if requested_module has version extension and allow explicit versions instead of just latest

1
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/include_modules.config

@ -53,6 +53,7 @@ set bootsupport_modules [list\
modules flagfilter\
modules funcl\
modules punk\
modules punkboot::utils\
modules punk::pipe\
modules punkapp\
modules punkcheck\

159
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/console-0.6.0.tm → src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/console-0.7.1.tm

@ -7,7 +7,7 @@
# (C) 2023
#
# @@ Meta Begin
# Application punk::console 0.6.0
# Application punk::console 0.7.1
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
@ -17,7 +17,7 @@
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::console 0 0.6.0]
#[manpage_begin punkshell_module_punk::console 0 0.7.1]
#[copyright "2024"]
#[titledesc {punk console}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk console}] [comment {-- Description at end of page heading --}]
@ -564,6 +564,30 @@ namespace eval punk::console {
set cinfo [punk::console::console_spec_resolve $inoutchannels]
set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]]
lassign $inoutchannels input output
#G-007 choke-point brokering: a query on a console owned by another thread runs in
#the owning thread - where the cooperative reader protocol (input_chunks_waiting),
#raw-mode arbitration and the authoritative settled can_respond live. Forwarding the
#whole call means the queueing, raw cycling and reader cooperation below all execute
#in the owner's context, and every query proc layered above this choke point
#inherits the routing. Routed before the local can_respond gate deliberately: a
#non-owner context's anchored view of the default console may be unsettled while
#the owner's is settled. The synchronous thread::send is safe because the owner
#services events while this thread blocks (the property the repl-installed vt52
#alias transport already relies on), and re-entry in the owner resolves to
#owner==self so the forward cannot ping-pong.
set route_owner [console_route_owner $inoutchannels]
if {$route_owner ne ""} {
try {
return [thread::send $route_owner [list ::punk::console::internal::get_ansi_response_payload -console $inoutchannels -expected_ms $expected -ignoreok $ignoreok -return $returntype -passthrough $passthrough $query $capturingendregex]]
} on error {errM erropts} {
#preserve the owner-side errorcode (where the transport propagates it) so
#callers can discriminate, e.g. {PUNK CONSOLE QUERY HOSTAGE_COOKED_READ}
#from the idle-reader guard
return -code error -errorcode [dict get $erropts -errorcode] "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM"
}
}
set governing_obj [dict get $cinfo object]
if {$governing_obj ne ""} {
set governing_cr [::opunk::Console::get.o_can_respond $governing_obj]
@ -590,6 +614,28 @@ namespace eval punk::console {
error "punk::console::get_ansi_response_payload input channel '$input' is closed or at eof - cannot receive terminal response"
}
#tcl 8.6 windows console idle-reader hostage guard (fail fast, no emission).
#With the console in cooked (line) mode and a readable handler armed on the input
#channel, the 8.6 channel driver has a blocking cooked-mode ReadConsole parked
#(arming is what posts it), and driver reads sample the console mode at issue time -
#the raw cycle below cannot rescue it. Any response would be swallowed by that read
#until the user presses Enter, then leak to the line reader as phantom input
#(~500ms timeout now, input corruption later). This is the idle-at-a-line-mode-prompt
#condition: the repl disarms its reader during command dispatch, so mid-command
#queries are unaffected, and raw mode reads cooperatively and is exempt. Typical
#trigger: a query from an after-script or worker thread firing while the shell sits
#at a line-mode prompt. Best-effort by design: a parked read can outlive a removed
#handler, so this catches the systematic case, not every conceivable one.
if {![tsv::get punk_console is_raw] && $input eq "stdin" && $::punk::console::has_twapi} {
set guard_conf [chan configure $input]
if {![dict exists $guard_conf -inputmode] && ![dict exists $guard_conf -mode]
&& [chan event $input readable] ne ""
&& ![catch {twapi::get_console_handle stdin}]} {
return -code error -errorcode [list PUNK CONSOLE QUERY HOSTAGE_COOKED_READ]\
"punk::console::get_ansi_response_payload refusing to emit query on '$input': tcl 8.6 console is in cooked (line) mode with an armed readable handler - the driver's parked cooked read would swallow the response until Enter (query issued from an idle line-mode prompt?). Use raw mode, or issue queries while the reader is disarmed (e.g. during repl command evaluation)."
}
}
#chunks from input that need to be handled by readers
upvar ::punk::console::input_chunks_waiting input_chunks_waiting
@ -684,6 +730,16 @@ namespace eval punk::console {
#For mintty-without-winpty (terminal presenting pipes) the twapi raw cycle was already a
#no-op via its get_console_handle early-return, so nothing is lost by skipping.
set input_is_console_or_tty [expr {[dict exists $previous_input_state -inputmode] || [dict exists $previous_input_state -mode]}]
if {!$input_is_console_or_tty && $input eq "stdin" && $::punk::console::has_twapi} {
#Tcl 8.6 windows console channels have no -inputmode configure key, so the
#dict test alone misclassifies a real 8.6 console as a pipe: the raw cycle
#is then skipped and the query response sits in the cooked line buffer until
#Enter - timeout here plus the response leaking to the line reader as phantom
#input. A twapi console handle for stdin is definitive. Channels other than
#stdin stay excluded - the guard's purpose (pipe probes must not flip the
#process console) is unchanged.
set input_is_console_or_tty [expr {![catch {twapi::get_console_handle stdin}]}]
}
if {![tsv::get punk_console is_raw]} {
set was_raw 0
if {$input_is_console_or_tty} {
@ -1988,6 +2044,16 @@ namespace eval punk::console {
#pending keystroke with a probe read here.
return 0
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key, so the dict test above
#misses them. Beyond the pending-keystroke concern, the probe read is fatal here:
#a read on a drained 8.6 console channel makes the channel driver park a blocking
#cooked-mode ReadConsole that cannot be cancelled by a later raw-mode flip - it
#swallows a terminal query response emitted afterwards (get_ansi_response_payload
#calls this guard immediately before its raw cycle) and only completes on Enter.
return 0
}
#pipe-like channel - probe without blocking to force eof detection
if {[catch {
set prior_blocking [dict get $conf -blocking]
@ -2036,6 +2102,14 @@ namespace eval punk::console {
#tty on unix-like platforms
return 1
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key - a twapi console
#handle for stdin is definitive: process stdin is the real console. Terminals
#presenting channels as pipes (mintty without winpty) have no console handle
#and fall through to the env heuristics below.
return 1
}
if {[input_at_eof $input]} {
return 0
}
@ -2405,6 +2479,37 @@ namespace eval punk::console {
set owner [punk::console::console_owner_get {stdin stdout}]
return [expr {$owner eq "" || $owner eq [thread::id]}]
}
punk::args::define {
@id -id ::punk::console::internal::console_route_owner
@cmd -name punk::console::internal::console_route_owner -summary\
"Owning thread a console operation must be brokered to, or empty string to operate locally."\
-help\
"Routing consults the ownership registry for the process-default console
{stdin stdout} only: std channel names are process-wide, so any context can
refer to the shared console, but exactly one context owns its reader and
raw-mode arbitration. Non-std channel names are thread-local - an {in out}
pair spec names channels of the calling thread, making that thread the
console's local context - so non-default pairs always operate locally
(a registry entry for such a pair qualifies the shared fact store; it is
not a routing target). A console anchored by code-interp/worker code is
therefore operated on directly by its anchoring context, with no round-trip.
Returns empty string when the caller is the owner or no live owner is
registered."
@values -min 1 -max 1
inoutchannels -type list -help\
"Canonical {in out} channel pair."
}
proc console_route_owner {inoutchannels} {
#see PUNKARGS id ::punk::console::internal::console_route_owner
if {[lindex $inoutchannels 0] ne "stdin" || [lindex $inoutchannels 1] ne "stdout"} {
return ""
}
set owner [punk::console::console_owner_get {stdin stdout}]
if {$owner eq "" || $owner eq [thread::id]} {
return ""
}
return $owner
}
punk::args::define {
@id -id ::punk::console::internal::console_share_qualifier
@cmd -name punk::console::internal::console_share_qualifier -summary\
@ -2809,7 +2914,14 @@ namespace eval punk::console {
proc ensure_object_integration {} {
variable object_integration_done
if {$object_integration_done} {
return
return 1
}
if {![namespace exists ::opunk::console]} {
#opunk::console not loaded - nothing to integrate with yet. In-module callers only
#reach here after a successful 'package require opunk::console'; external/manual
#callers may call speculatively. Deliberately does not set object_integration_done,
#so a later call after opunk::console loads performs the wiring.
return 0
}
set ::opunk::console::waiting_chunks_arrayvar ::punk::console::input_chunks_waiting
if {$::opunk::console::size_query_provider eq ""} {
@ -2820,8 +2932,27 @@ namespace eval punk::console {
#consult-time liveness validation still cover the default console there)
if {[info exists ::opunk::console::lifecycle_callback] && $::opunk::console::lifecycle_callback eq ""} {
set ::opunk::console::lifecycle_callback [list ::punk::console::internal::object_console_lifecycle]
#catch-up registration: anchors created in this interp before the callback was
#wired never fired a 'created' event, so their consoles have no recorded owner
#(seen when user code does 'package require opunk::console; opunk::console::create ...'
#before any punk::console object operation triggers this wiring). Anchors are
#per-interp/per-thread, so the anchoring context is this thread - register it.
#Only fills empty entries: an existing live registration is newer information than
#these pre-wiring anchors (and for the default console first-registration wins).
foreach v [info vars ::opunk::console::instances::*] {
if {![info exists $v]} {
continue
}
if {[catch {::opunk::Console::channels [set $v]} channels]} {
continue
}
if {[console_owner_get $channels] eq ""} {
console_owner_register $channels
}
}
}
set object_integration_done 1
return 1
}
lappend PUNKARGS [list {
@ -2892,12 +3023,19 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::move 2000 2000]
#flush before querying: the position query may execute in the console-owning
#thread (G-007 routing), whose flush acts on its own channel instance for the same
#OS handle - an unflushed move here would leave the terminal reporting the
#unmoved cursor position (seen as 'columns 1' on tcl 8.6 where the -winsize
#shortcut is unavailable and this mechanism actually runs)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::move $start_row $start_col][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::move $start_row $start_col]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -2931,12 +3069,16 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::cursor_save_dec][punk::ansi::move 2000 2000]
#flush before querying - see get_size_using_cursormove (G-007 routing: the query
#may flush a different channel instance in the console-owning thread)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::cursor_restore][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::cursor_restore_dec]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -4186,6 +4328,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before each cursor query: the query may execute in the console-owning thread
#(G-007 routing) whose flush acts on its own channel instance - unflushed emissions
#here would be measured as if they never happened (see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -4207,6 +4353,7 @@ namespace eval punk::console {
} errM]} {
puts stderr "test_char_width couldn't emit this string - \nerror: $errM"
}
flush $out ;#the test emission must reach the terminal before the (possibly routed) query measures it
set response [punk::console::get_cursor_pos $inoutchannels]
lassign [split $response ";"] _row2 col2
@ -4261,6 +4408,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before the cursor query - the alt-screen/move/erase emissions above must reach
#the terminal first (the query may flush a different channel instance in the
#console-owning thread under G-007 routing - see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -6202,7 +6353,7 @@ namespace eval ::punk::args::register {
## Ready
package provide punk::console [namespace eval punk::console {
variable version
set version 0.6.0
set version 0.7.1
}]
return

51
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/repl-0.2.1.tm → src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/repl-0.2.2.tm

@ -1635,6 +1635,7 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
#note -inputmode not available in Tcl 8.6 for chan configure!
#According to DKF - -buffering option doesn't affect input channels
set rawmode 0
set waiting_needs_reader 0 ;#set when the 8.6-console waiting-chunks path skips its read on a drained channel - the readable handler must be armed (not the after-idle reinvoke) or the pending partial line could never complete
set original_input_conf [chan configure $inputchan] ;#whether repl is in line or raw mode - we restore the inputchan (stdin) state
if {[dict exists $original_input_conf -inputmode]} {
@ -1693,9 +1694,46 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set chunk [read $inputchan]
set hostage_noread 0
if {"windows" eq $::tcl_platform(platform)
&& ![dict exists $original_input_conf -inputmode]
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]} {
#tcl 8.6 windows console channel (no -inputmode configure key, real console
#handle). On the 8.6 console driver a read on a *drained* channel parks a
#blocking cooked-mode ReadConsole that a later raw-mode flip cannot cancel:
#it swallows terminal query responses until Enter (see punk::console 0.7.1
#buildversion notes). This waiting-chunks path is normally entered via
#'after idle' with the channel drained - only consume what is already in the
#Tcl channel buffer (sized read - no driver probe); when nothing is buffered,
#process the stashed complete lines without reading and arm the readable
#handler for the rest.
set avail [chan pending input $inputchan]
if {$avail > 0} {
set chunk [read $inputchan $avail]
} else {
set chunk ""
set hostage_noread 1
set waiting_needs_reader 1
}
} else {
set chunk [read $inputchan]
}
set chunksize [string length $chunk]
if {$chunksize > 0} {
if {$hostage_noread} {
#nothing in the Tcl buffer and we must not probe the drained driver.
#Process the complete stashed lines now; a remaining partial line can only
#progress via more user input, which the armed readable handler delivers.
if {[llength $stdinlines]} {
punk::repl::repl_handler_restorechannel_if_not_eof $inputchan $original_input_conf
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
}
if {$waitingchunk ne ""} {
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set input_chunks_waiting($inputchan) [list]
}
} elseif {$chunksize > 0} {
if {[string index $chunk end] eq "\n"} {
lappend stdinlines $waitingchunk[string range $chunk 0 end-1]
#punk::console::cursorsave_move_emitblock_return 30 30 "repl_handler num_stdinlines [llength $stdinlines] chunk:$yellow[ansistring VIEW -lf 1 $chunk][a] fblocked:[fblocked $inputchan] pending:[chan pending input stdin]"
@ -1864,7 +1902,12 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
##################################################################################
#Re-enable channel read handler only if no waiting chunks - must process in order
##################################################################################
if {![llength $input_chunks_waiting($inputchan)]} {
if {![llength $input_chunks_waiting($inputchan)] || $waiting_needs_reader} {
#waiting_needs_reader: the 8.6-console waiting-chunks path found nothing in the
#Tcl channel buffer and must not probe the drained driver - the waiting data is
#an incomplete line that can only progress when the user types more, so arm the
#reader (the driver listening for input is its job here) instead of an after-idle
#reinvoke that could never make progress.
chan event $inputchan readable [list ::repl::repl_handler $inputchan $readmore $prompt_config]
} else {
#review
@ -4172,7 +4215,7 @@ namespace eval repl {
}
package provide punk::repl [namespace eval punk::repl {
variable version
set version 0.2.1
set version 0.2.2
}]
#repl::start $program_read_stdin_pipe

214
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punkboot/utils-0.1.1.tm

@ -0,0 +1,214 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use 'pmix make' or src/make.tcl to update from <pkg>-buildversion.txt
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2023
#
# @@ Meta Begin
# Application punkboot::utils 0.1.1
# Meta platform tcl
# Meta license BSD
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
##e.g package require frobz
package require Tcl 8.6-
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
namespace eval punkboot::utils {
variable version 0.1.0
namespace export parse_punkproject_version read_punkproject_version read_changelog_latest_version vcs_dirty_warnings
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::parse_punkproject_version
@cmd -name "::punkboot::utils::parse_punkproject_version"\
-summary\
"Parse the [project] version from TOML content string"\
-help\
"Scans TOML content for the [project] section and returns
the value of the version key as a string. Returns an empty
string if the section or key is not found."
@leaders
content -type string -optional 0 -help\
"TOML content string to parse"
}]
}
proc parse_punkproject_version {content} {
set in_project 0
foreach line [split $content \n] {
set trimmed [string trim $line]
if {[string index $trimmed 0] eq {[} && [string index $trimmed end] eq {]}} {
set in_project [expr {$trimmed eq "\[project\]"}]
continue
}
if {$in_project && [regexp {^version\s*=\s*"([^"]+)"} $trimmed _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_punkproject_version
@cmd -name "::punkboot::utils::read_punkproject_version"\
-summary\
"Read the [project] version from a TOML file"\
-help\
"Reads the file at tomlfile and returns the [project]
version value as a string. Returns an empty string if the
file does not exist or the version is not found."
@leaders
tomlfile -type string -optional 0 -help\
"Path to the TOML file to read"
}]
}
proc read_punkproject_version {tomlfile} {
if {![file exists $tomlfile]} {return ""}
set fh [open $tomlfile r]
try {
set content [read $fh]
} finally {
close $fh
}
return [::punkboot::utils::parse_punkproject_version $content]
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_changelog_latest_version
@cmd -name "::punkboot::utils::read_changelog_latest_version"\
-summary\
"Read the latest ## [X.Y.Z] version header from a changelog file"\
-help\
"Scans the changelog file from the top and returns the
version string from the first '## [X.Y.Z]' header line.
Returns an empty string if the file does not exist or no
matching header is found."
@leaders
changelogfile -type string -optional 0 -help\
"Path to the changelog file to read"
}]
}
proc read_changelog_latest_version {changelogfile} {
if {![file exists $changelogfile]} {return ""}
set fh [open $changelogfile r]
try {
set content [read $fh]
} finally {
close $fh
}
foreach line [split $content \n] {
if {[regexp {^##\s*\[([0-9][^\]]*)\]} $line _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::vcs_dirty_warnings
@cmd -name "::punkboot::utils::vcs_dirty_warnings"\
-summary\
"Warning lines for uncommitted VCS changes above a path"\
-help\
"Walks up from path to the nearest fossil and/or git root and
returns warning lines describing uncommitted changes.
Artifacts built from a dirty source tree have no committed
provenance - callers such as make.tcl vendorupdate use this
to flag source projects that should be committed first.
checkedrootsvar names a dict variable in the caller's scope
used to report each VCS root at most once across calls.
label, if supplied, is prefixed into each warning to name
the calling operation (e.g. 'vendorupdate').
Returns an empty list if the path is clean, unversioned,
missing, already reported, or state cannot be determined."
@leaders
path -type string -optional 0 -help\
"Path to check; the nearest enclosing fossil/git root is examined"
checkedrootsvar -type string -optional 0 -help\
"Name of a dict variable in the caller's scope for dedupe across calls"
label -type string -optional 1 -default "" -help\
"Operation name prefixed into warnings"
}]
}
proc vcs_dirty_warnings {path checkedrootsvar {label ""}} {
upvar 1 $checkedrootsvar checkedroots
if {![info exists checkedroots]} {set checkedroots [dict create]}
if {$label ne ""} {set label "$label "}
set warnings [list]
set dir [file normalize $path]
if {![file isdirectory $dir]} {
return $warnings ;#missing path - not this proc's business to report
}
set fossilroot ""
set gitroot ""
while {1} {
if {$fossilroot eq "" && ([file exists [file join $dir .fslckout]] || [file exists [file join $dir _FOSSIL_]])} {
set fossilroot $dir
}
if {$gitroot eq "" && [file exists [file join $dir .git]]} {
set gitroot $dir
}
if {$fossilroot ne "" || $gitroot ne ""} {
break ;#report at the nearest VCS root(s); a dual git+fossil root is caught in one pass
}
set parent [file dirname $dir]
if {$parent eq $dir} { break }
set dir $parent
}
if {$fossilroot ne "" && ![dict exists $checkedroots fossil,$fossilroot] && [llength [auto_execok fossil]]} {
dict set checkedroots fossil,$fossilroot 1
#fossil changes must run from within the checkout
set original_cwd [pwd]
if {[catch {
cd $fossilroot
set fchanges [string trim [exec {*}[auto_execok fossil] changes]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine fossil state of source project at $fossilroot ($errM)"
} elseif {$fchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $fossilroot has uncommitted fossil changes ([llength [split $fchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
cd $original_cwd
}
if {$gitroot ne "" && ![dict exists $checkedroots git,$gitroot] && [llength [auto_execok git]]} {
dict set checkedroots git,$gitroot 1
if {[catch {
set gchanges [string trim [exec {*}[auto_execok git] -C $gitroot status --porcelain]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine git state of source project at $gitroot ($errM)"
} elseif {$gchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $gitroot has uncommitted git changes ([llength [split $gchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
}
return $warnings
}
}
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punkboot::utils ::punkboot::utils::argdoc
}
## Ready
package provide punkboot::utils [tcl::namespace::eval punkboot::utils {
variable version
#- this version number, exactly 0.1.1, is a literal used in src module folders
#- we refer to this sometimes as the magic version number
set version 0.1.1
}]
return

42
src/bootsupport/modules/shellfilter-0.2.2.tm → src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/shellfilter-0.2.3.tm

@ -2849,9 +2849,19 @@ namespace eval shellfilter {
set id_out [shellfilter::stack::add $outchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_out -pipechan $outpipechan]]
set id_err [shellfilter::stack::add $errchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_err -pipechan $errpipechan]]
#From here to the stack::remove calls the caller's channels are diverted:
#any error escaping this region would leave stdout/stderr broken for the rest of the
#process with all subsequent output silently lost - hence the try/finally below, and
#catch-guards on decorative logging (stack-status table rendering runs punk::ansi/textblock
#code that can fail e.g when mismatched module snapshots are loaded - a rendering failure
#must degrade to a log note, never abort the run).
log::critical {
::shellfilter::log::write $runtag "[punk::ansi::ansistrip [shellfilter::stack status]]\nchan names:[chan names]"
if {[catch {punk::ansi::ansistrip [shellfilter::stack status]} stackstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $stackstatus)"
}
::shellfilter::log::write $runtag "$stackstatus\nchan names:[chan names]"
}
try {
# need to use os level channel handle for stdin - try named pipes (or even sockets) instead of fifo2 for this
# If non os-level channel - the command can't be run with the redirection
# stderr/stdout can be run with non-os handles in the call -
@ -2919,22 +2929,28 @@ namespace eval shellfilter {
#----------------------------------------------------------------------------------------------
#todo - change to log::debug
log::critical {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
if {[catch {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
}
} errstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $errstatus)"
}
::shellfilter::log::write $runtag "shellfilter::stack status after execution: \n$stackstatus\nchan:names [chan names]"
}
#----------------------------------------------------------------------------------------------
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
} finally {
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack - must happen even if the body above errored,
#or the caller's stdout/stderr stay diverted and all subsequent process output is silently lost.
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
}
log::info {::shellfilter::log::write $runtag " return '$exitinfo'"}
@ -3811,5 +3827,5 @@ namespace eval shellfilter {
package provide shellfilter [namespace eval shellfilter {
variable version
set version 0.2.2
set version 0.2.3
}]

421
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/shellrun-0.1.2.tm → src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/shellrun-0.1.4.tm

@ -5,6 +5,7 @@
package require shellfilter
package require punk::ansi
package require punk::args
#NOTE: the run,runout,runerr,runx commands only produce an error if the command didn't run.
# - If it did run, but there was a non-zero exitcode it is up to the application to check that.
@ -79,76 +80,45 @@ namespace eval shellrun {
#maintenance: similar used in punk::ns & punk::winrun
#todo - take runopts + aliases as args
#longopts must be passed as a single item ie --timeout=100 not --timeout 100
proc get_run_opts {arglist} {
if {[catch {
set callerinfo [info level -1]
} errM]} {
set caller ""
} else {
set caller [lindex $callerinfo 0]
}
#we provide -nonewline even for 'run' even though run doesn't deliver stderr or stdout to the tcl return value
#This is for compatibility with other runX commands, and the difference is also visible when calling from repl.
set known_runopts [list "-echo" "-e" "-nonewline" "-n" "-tcl" "-debug"]
set known_longopts [list "--timeout"]
set known_longopts_msg ""
foreach lng $known_longopts {
append known_longopts_msg "${lng}=val "
}
set aliases [list "-e" "-echo" "-echo" "-echo" "-n" "-nonewline" "-nonewline" "-nonewline" "-tcl" "-tcl" "-debug" "-debug"] ;#include map to self
set runopts [list]
set runoptslong [list]
set cmdargs [list]
set idx_first_cmdarg [lsearch -not $arglist "-*"]
set allopts [lrange $arglist 0 $idx_first_cmdarg-1]
set cmdargs [lrange $arglist $idx_first_cmdarg end]
foreach o $allopts {
if {[string match --* $o]} {
lassign [split $o =] flagpart valpart
if {$valpart eq ""} {
error "$caller: longopt $o seems to be missing a value - must be of form --option=value"
}
if {$flagpart ni $known_longopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runoptslong $flagpart $valpart
} else {
if {$o ni $known_runopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runopts [dict get $aliases $o]
}
}
return [list runopts $runopts runoptslong $runoptslong cmdargs $cmdargs]
}
#todo - investigate cause of punk86 run hanging sometimes. An 'after 500' before exit in the called script fixes the issue. punk87 doesn't seem to be affected.
lappend PUNKARGS [list {
@id -id ::shellrun::run
@cmd -name "shellrun::run"\
-summary "Run an external command, streaming stdout/stderr to the console in real-time"\
-help\
"Run an external command and return the shellfilter exitinfo dict.
stdout and stderr are written to the console as they arrive;
nothing is captured for the return value. The exitcode is
part of the returned dict — a non-zero exitcode is NOT raised
as a Tcl error, the caller must check it explicitly.
Use -tcl to run a Tcl script/command instead of an external
process; in that case the returned dict carries a 'result'
key (or error/errorCode/errorInfo on failure) instead of an
exitcode."
@leaders -min 0 -max 0
@opts
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-nonewline -type none -help\
"Accepted for compatibility with runout/runerr/runx. Has no
effect on run because run does not capture or return stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc run {args} {
#set_last_run_display [list]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set runoptslong [dict get $splitargs runoptslong]
#set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::run]
lassign [dict values $argd] leaders opts values received
@ -206,11 +176,25 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runconsole
@cmd -name "shellrun::runconsole"\
-summary "Run a command via exec with stdout/stderr directed to the console, mimicking Tcl's unknown handler"\
-help\
"Resolve cmdname via auto_execok and exec it with stdout
and stderr redirected to the active console. The result is
returned via uplevel with the exec result/options dict,
mirroring the way Tcl's unknown proc handles external
commands.
Unlike run, this uses exec semantics — there is no
exitcode in the return value and stdout/stderr are not
captured separately."
@leaders -min 0 -max 0
@opts
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the command to resolve via auto_execok and run."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed to the resolved command."
}]
#run in the way tcl unknown does - but without regard to auto_noexec
proc runconsole {args} {
@ -256,16 +240,42 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runout
@cmd -name "shellrun::runout"\
-summary "Run an external command and return its stdout"\
-help\
"Run an external command, capture stdout and stderr, and
return stdout as the result. stderr and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stdout.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runout {args} {
set argd [punk::args::parse $args withid ::shellrun::runout]
@ -284,11 +294,6 @@ namespace eval shellrun {
set runerr ""
set RST [a]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
#puts stdout "RUNOUT cmdargs: $cmdargs"
#todo add -data boolean and -data lastwrite to -settings with default being -data all
@ -422,16 +427,42 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runerr
@cmd -name "shellrun::runerr"\
-summary "Run an external command and return its stderr"\
-help\
"Run an external command, capture stdout and stderr, and
return stderr as the result. stdout and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stderr.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runerr {args} {
set argd [punk::args::parse $args withid ::shellrun::runerr]
@ -449,10 +480,6 @@ namespace eval shellrun {
set runout ""
set runerr ""
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
@ -567,6 +594,43 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runx
@cmd -name "shellrun::runx"\
-summary "Run an external command and return a dict of stdout, stderr, and exitcode"\
-help\
"Run an external command, capture stdout and stderr, and
return a dict containing stdout, stderr, and exitcode (or
result/error/errorCode/errorInfo for -tcl calls).
No console output is produced unless -echo is given. Use
-nonewline to strip trailing newlines from captured stdout
and stderr before returning.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runx {args} {
#set_last_run_display [list]
variable runout
@ -574,11 +638,10 @@ namespace eval shellrun {
set runout ""
set runerr ""
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::runx]
lassign [dict values $argd] leaders opts values received
if {"-nonewline" in $runopts} {
if {[dict exists $received "-nonewline"]} {
set nonewline 1
} else {
set nonewline 0
@ -586,7 +649,7 @@ namespace eval shellrun {
#shellfilter::stack::remove stdout $::repl::id_outstack
if {"-echo" in $runopts} {
if {[dict exists $received "-echo"]} {
#float to ensure repl transform doesn't interfere with the output data
set stderr_stackid [shellfilter::stack::add stderr tee_to_var -action float -settings {-varname ::shellrun::runerr}]
set stdout_stackid [shellfilter::stack::add stdout tee_to_var -action float -settings {-varname ::shellrun::runout}]
@ -601,10 +664,23 @@ namespace eval shellrun {
set stdout_stackid [shellfilter::stack::add stdout var -action float-locked -junction 1 -settings {-varname ::shellrun::runout}]
}
set callopts ""
if {"-tcl" in $runopts} {
append callopts " -tclscript 1"
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
}
if {[dict exists $received "-debug"]} {
dict set callopts -debug 1
}
if {[dict exists $received --timeout]} {
dict set callopts -timeout [dict get $opts --timeout]
}
set cmdname [dict get $values cmdname]
if {[dict exists $received cmdarg]} {
set cmdarglist [dict get $values cmdarg]
} else {
set cmdarglist {}
}
set cmdargs [concat $cmdname $cmdarglist]
#set exitinfo [shellfilter::run $cmdargs -teehandle punksh -inbuffering none -outbuffering none -stdinhandler ::repl::repl_handler]
set exitinfo [shellfilter::run $cmdargs {*}$callopts -teehandle punksh -inbuffering none -outbuffering none]
@ -616,7 +692,7 @@ namespace eval shellrun {
flush stdout
if {[dict exists $exitinfo error]} {
if {"-tcl" in $runopts} {
if {[dict exists $received "-tcl"]} {
} else {
#todo - check errorInfo makes sense.. return -code? tailcall?
@ -826,30 +902,161 @@ namespace eval shellrun {
return $exitinfo
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_run
@cmd -name "shellrun::sh_run"\
-summary "Run a command line via sh -c, streaming stdout/stderr to the console"\
-help\
"Wrapper around shellrun::run that invokes the command line
via sh -c. The entire command line after options is passed
as a single argument to sh -c.
Accepts the same options as shellrun::run except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-nonewline -type none -help\
"Accepted for compatibility. Has no effect on run-based
commands because run does not capture output."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_run {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_run]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
#e.g sh -c "ls -l *"
#we pass cmdargs to sh -c as a list, not individually
tailcall shellrun::run {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runout
@cmd -name "shellrun::sh_runout"\
-summary "Run a command line via sh -c and return its stdout"\
-help\
"Wrapper around shellrun::runout that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runout except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runout {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runout]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runout {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runerr
@cmd -name "shellrun::sh_runerr"\
-summary "Run a command line via sh -c and return its stderr"\
-help\
"Wrapper around shellrun::runerr that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runerr except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runerr {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runerr]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runerr {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runx
@cmd -name "shellrun::sh_runx"\
-summary "Run a command line via sh -c and return a dict of stdout, stderr, and exitcode"\
-help\
"Wrapper around shellrun::runx that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runx except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runx {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runx]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runx {*}$runopts sh -c $cmdargs
}
}
@ -895,5 +1102,5 @@ namespace eval ::punk::args::register {
package provide shellrun [namespace eval shellrun {
variable version
set version 0.1.2
set version 0.1.4
}]

106
src/project_layouts/custom/_project/punk.project-0.1/src/make.tcl

@ -22,7 +22,7 @@ namespace eval ::punkboot {
variable pkg_requirements [list]; variable pkg_missing [list];variable pkg_loaded [list]
variable non_help_flags [list -k]
variable help_flags [list -help --help /? -h]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate ]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate projectversion]
}
@ -1358,6 +1358,8 @@ proc ::punkboot::punkboot_gethelp {args} {
append h " - show module/library paths and any potentially problematic packages for running this script" \n
append h " $scriptname shell" \n
append h " - run the punk shell using bootsupport libraries." \n
append h " $scriptname projectversion" \n
append h " - advisory check: verify CHANGELOG.md matches punkproject.toml and warn if src/ has changes since the last project-version bump." \n \n
append h "" \n
if {[llength [dict get $pkg_availability missing]] || [llength [dict get $pkg_availability broken]]} {
set has_recommended 0
@ -1532,7 +1534,7 @@ if {[lsearch $::argv -k] >= 0} {
# - minor bump -> prompt user; backward-compat aliases *should* keep old bootsupport functional
# - patch bump -> warn and proceed (non-breaking)
# 'check' is exempt in all cases the warning is shown at the end of its output instead.
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check"} {
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check" && $::punkboot::command ne "projectversion"} {
set _stale $::punkboot::stale_bootsupport
set _have_major 0
set _have_minor 0
@ -1825,8 +1827,88 @@ if {$::punkboot::command eq "info"} {
exit 0
}
if {$::punkboot::command eq "projectversion"} {
package require punkboot::utils
set sep [string repeat - 75]
puts stdout $sep
puts stdout "project version check"
puts stdout $sep
set pp_file [file join $projectroot punkproject.toml]
set cl_file [file join $projectroot CHANGELOG.md]
set src_folder [file join $projectroot src]
set pp_version [::punkboot::utils::read_punkproject_version $pp_file]
set cl_version [::punkboot::utils::read_changelog_latest_version $cl_file]
puts stdout "punkproject.toml version : [expr {[string length $pp_version] ? $pp_version : "(not found)"}]"
puts stdout "CHANGELOG.md version : [expr {[string length $cl_version] ? $cl_version : "(not found)"}]"
# 1. Consistency: CHANGELOG latest header must match punkproject.toml version.
if {![string length $pp_version]} {
puts stderr "WARNING: could not read version from $pp_file"
} elseif {![string length $cl_version]} {
puts stderr "WARNING: no '## [X.Y.Z]' version header found in $cl_file"
} elseif {$pp_version ne $cl_version} {
puts stderr "WARNING: version mismatch punkproject.toml=$pp_version but CHANGELOG.md=$cl_version"
puts stderr " The latest '## [X.Y.Z]' header in CHANGELOG.md must match the version in punkproject.toml."
} else {
puts stdout "consistency: OK (versions match)"
}
# 2. Staleness: warn if src/ has git commits since the last commit
# touching punkproject.toml (version bump may be overdue).
# If punkproject.toml has an uncommitted version change, the bump is
# already in progress report that only a commit is needed.
# Best-effort silently skipped if git is unavailable or the repo
# is not under git revision control.
set pp_commit ""
if {![catch {exec git -C $projectroot log -1 --format=%H -- punkproject.toml} pp_raw]} {
set pp_commit [string trim $pp_raw]
}
if {[string length $pp_commit]} {
if {![catch {exec git -C $projectroot log --oneline ${pp_commit}..HEAD -- src/} src_raw]} {
set src_changes [string trim $src_raw]
if {[string length $src_changes]} {
set n [llength [split $src_changes \n]]
# Check whether a bump is already staged in the working tree
# (punkproject.toml modified but uncommitted). If the working-
# tree version differs from HEAD, the bump is done only a
# commit is needed.
set head_version ""
if {![catch {exec git -C $projectroot show HEAD:punkproject.toml} head_raw]} {
set head_version [::punkboot::utils::parse_punkproject_version $head_raw]
}
set bump_uncommitted [expr {[string length $head_version] && [string length $pp_version] && $pp_version ne $head_version}]
if {$bump_uncommitted} {
puts stderr "NOTE: $n commit(s) in src/ since last punkproject.toml commit, but punkproject.toml already bumped ($head_version -> $pp_version, uncommitted)."
puts stderr " Commit punkproject.toml (and CHANGELOG.md) to record the bump and clear this warning."
} else {
puts stderr "WARNING: $n commit(s) in src/ since last punkproject.toml change a project-version bump may be overdue."
puts stderr " Review the changes and bump punkproject.toml per the root AGENTS.md 'Project Versioning' section."
}
puts stderr " (showing first 5 commits below)"
set i 0
foreach line [split $src_changes \n] {
if {$i >= 5} break
puts stderr " $line"
incr i
}
} else {
puts stdout "staleness: OK (no src/ changes since last punkproject.toml commit)"
}
} else {
puts stdout "staleness: skipped (git query failed)"
}
} else {
puts stdout "staleness: skipped (punkproject.toml has no git history or git unavailable)"
}
puts stdout $sep
exit 0
}
if {$::punkboot::command eq "shell"} {
package require struct::list
@ -1890,10 +1972,20 @@ if {$::punkboot::command eq "vendorupdate"} {
#puts "-- [tcl::tm::list] --"
puts stdout "Updating vendor modules in src folder"
#dirty-checkout provenance warnings via punkboot::utils::vcs_dirty_warnings (loaded from bootsupport).
#Guarded require: vendorupdate must keep working even if the bootsupport punkboot::utils copy is
#missing or stale (it may be the very thing being repaired) - the check degrades to skipped.
set have_vcs_dirty_check [expr {![catch {package require punkboot::utils}] && [llength [info commands ::punkboot::utils::vcs_dirty_warnings]]}]
if {!$have_vcs_dirty_check} {
puts stderr "WARNING: vendorupdate dirty-checkout provenance check unavailable (punkboot::utils vcs_dirty_warnings not loadable from bootsupport) - continuing without it"
}
proc vendor_localupdate {projectroot} {
set local_modules [list]
set git_modules [list]
set fossil_modules [list]
set checked_source_paths [dict create] ;#skip repeat walk-ups for identical source paths
set checked_vcs_roots [dict create] ;#report each VCS root at most once per run
set sourcefolder $projectroot/src
#todo vendor/lib
set vendorlibfolders [glob -nocomplain -dir $sourcefolder -type d -tails vendorlib_tcl*]
@ -1939,6 +2031,16 @@ if {$::punkboot::command eq "vendorupdate"} {
} else {
set srclocation [file join $localpath $module_subpath]
}
#dirty-checkout provenance check - each VCS root reported at most once per run
if {$::have_vcs_dirty_check} {
set normsrclocation [file normalize $srclocation]
if {![dict exists $checked_source_paths $normsrclocation]} {
dict set checked_source_paths $normsrclocation 1
foreach w [::punkboot::utils::vcs_dirty_warnings $normsrclocation checked_vcs_roots vendorupdate] {
puts stderr $w
}
}
}
#puts stdout "$relpath $module $module_subpath $srclocation"
#todo - check if requested_module has version extension and allow explicit versions instead of just latest

1
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/include_modules.config

@ -53,6 +53,7 @@ set bootsupport_modules [list\
modules flagfilter\
modules funcl\
modules punk\
modules punkboot::utils\
modules punk::pipe\
modules punkapp\
modules punkcheck\

159
src/bootsupport/modules/punk/console-0.6.0.tm → src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/console-0.7.1.tm

@ -7,7 +7,7 @@
# (C) 2023
#
# @@ Meta Begin
# Application punk::console 0.6.0
# Application punk::console 0.7.1
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
@ -17,7 +17,7 @@
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::console 0 0.6.0]
#[manpage_begin punkshell_module_punk::console 0 0.7.1]
#[copyright "2024"]
#[titledesc {punk console}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk console}] [comment {-- Description at end of page heading --}]
@ -564,6 +564,30 @@ namespace eval punk::console {
set cinfo [punk::console::console_spec_resolve $inoutchannels]
set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]]
lassign $inoutchannels input output
#G-007 choke-point brokering: a query on a console owned by another thread runs in
#the owning thread - where the cooperative reader protocol (input_chunks_waiting),
#raw-mode arbitration and the authoritative settled can_respond live. Forwarding the
#whole call means the queueing, raw cycling and reader cooperation below all execute
#in the owner's context, and every query proc layered above this choke point
#inherits the routing. Routed before the local can_respond gate deliberately: a
#non-owner context's anchored view of the default console may be unsettled while
#the owner's is settled. The synchronous thread::send is safe because the owner
#services events while this thread blocks (the property the repl-installed vt52
#alias transport already relies on), and re-entry in the owner resolves to
#owner==self so the forward cannot ping-pong.
set route_owner [console_route_owner $inoutchannels]
if {$route_owner ne ""} {
try {
return [thread::send $route_owner [list ::punk::console::internal::get_ansi_response_payload -console $inoutchannels -expected_ms $expected -ignoreok $ignoreok -return $returntype -passthrough $passthrough $query $capturingendregex]]
} on error {errM erropts} {
#preserve the owner-side errorcode (where the transport propagates it) so
#callers can discriminate, e.g. {PUNK CONSOLE QUERY HOSTAGE_COOKED_READ}
#from the idle-reader guard
return -code error -errorcode [dict get $erropts -errorcode] "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM"
}
}
set governing_obj [dict get $cinfo object]
if {$governing_obj ne ""} {
set governing_cr [::opunk::Console::get.o_can_respond $governing_obj]
@ -590,6 +614,28 @@ namespace eval punk::console {
error "punk::console::get_ansi_response_payload input channel '$input' is closed or at eof - cannot receive terminal response"
}
#tcl 8.6 windows console idle-reader hostage guard (fail fast, no emission).
#With the console in cooked (line) mode and a readable handler armed on the input
#channel, the 8.6 channel driver has a blocking cooked-mode ReadConsole parked
#(arming is what posts it), and driver reads sample the console mode at issue time -
#the raw cycle below cannot rescue it. Any response would be swallowed by that read
#until the user presses Enter, then leak to the line reader as phantom input
#(~500ms timeout now, input corruption later). This is the idle-at-a-line-mode-prompt
#condition: the repl disarms its reader during command dispatch, so mid-command
#queries are unaffected, and raw mode reads cooperatively and is exempt. Typical
#trigger: a query from an after-script or worker thread firing while the shell sits
#at a line-mode prompt. Best-effort by design: a parked read can outlive a removed
#handler, so this catches the systematic case, not every conceivable one.
if {![tsv::get punk_console is_raw] && $input eq "stdin" && $::punk::console::has_twapi} {
set guard_conf [chan configure $input]
if {![dict exists $guard_conf -inputmode] && ![dict exists $guard_conf -mode]
&& [chan event $input readable] ne ""
&& ![catch {twapi::get_console_handle stdin}]} {
return -code error -errorcode [list PUNK CONSOLE QUERY HOSTAGE_COOKED_READ]\
"punk::console::get_ansi_response_payload refusing to emit query on '$input': tcl 8.6 console is in cooked (line) mode with an armed readable handler - the driver's parked cooked read would swallow the response until Enter (query issued from an idle line-mode prompt?). Use raw mode, or issue queries while the reader is disarmed (e.g. during repl command evaluation)."
}
}
#chunks from input that need to be handled by readers
upvar ::punk::console::input_chunks_waiting input_chunks_waiting
@ -684,6 +730,16 @@ namespace eval punk::console {
#For mintty-without-winpty (terminal presenting pipes) the twapi raw cycle was already a
#no-op via its get_console_handle early-return, so nothing is lost by skipping.
set input_is_console_or_tty [expr {[dict exists $previous_input_state -inputmode] || [dict exists $previous_input_state -mode]}]
if {!$input_is_console_or_tty && $input eq "stdin" && $::punk::console::has_twapi} {
#Tcl 8.6 windows console channels have no -inputmode configure key, so the
#dict test alone misclassifies a real 8.6 console as a pipe: the raw cycle
#is then skipped and the query response sits in the cooked line buffer until
#Enter - timeout here plus the response leaking to the line reader as phantom
#input. A twapi console handle for stdin is definitive. Channels other than
#stdin stay excluded - the guard's purpose (pipe probes must not flip the
#process console) is unchanged.
set input_is_console_or_tty [expr {![catch {twapi::get_console_handle stdin}]}]
}
if {![tsv::get punk_console is_raw]} {
set was_raw 0
if {$input_is_console_or_tty} {
@ -1988,6 +2044,16 @@ namespace eval punk::console {
#pending keystroke with a probe read here.
return 0
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key, so the dict test above
#misses them. Beyond the pending-keystroke concern, the probe read is fatal here:
#a read on a drained 8.6 console channel makes the channel driver park a blocking
#cooked-mode ReadConsole that cannot be cancelled by a later raw-mode flip - it
#swallows a terminal query response emitted afterwards (get_ansi_response_payload
#calls this guard immediately before its raw cycle) and only completes on Enter.
return 0
}
#pipe-like channel - probe without blocking to force eof detection
if {[catch {
set prior_blocking [dict get $conf -blocking]
@ -2036,6 +2102,14 @@ namespace eval punk::console {
#tty on unix-like platforms
return 1
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key - a twapi console
#handle for stdin is definitive: process stdin is the real console. Terminals
#presenting channels as pipes (mintty without winpty) have no console handle
#and fall through to the env heuristics below.
return 1
}
if {[input_at_eof $input]} {
return 0
}
@ -2405,6 +2479,37 @@ namespace eval punk::console {
set owner [punk::console::console_owner_get {stdin stdout}]
return [expr {$owner eq "" || $owner eq [thread::id]}]
}
punk::args::define {
@id -id ::punk::console::internal::console_route_owner
@cmd -name punk::console::internal::console_route_owner -summary\
"Owning thread a console operation must be brokered to, or empty string to operate locally."\
-help\
"Routing consults the ownership registry for the process-default console
{stdin stdout} only: std channel names are process-wide, so any context can
refer to the shared console, but exactly one context owns its reader and
raw-mode arbitration. Non-std channel names are thread-local - an {in out}
pair spec names channels of the calling thread, making that thread the
console's local context - so non-default pairs always operate locally
(a registry entry for such a pair qualifies the shared fact store; it is
not a routing target). A console anchored by code-interp/worker code is
therefore operated on directly by its anchoring context, with no round-trip.
Returns empty string when the caller is the owner or no live owner is
registered."
@values -min 1 -max 1
inoutchannels -type list -help\
"Canonical {in out} channel pair."
}
proc console_route_owner {inoutchannels} {
#see PUNKARGS id ::punk::console::internal::console_route_owner
if {[lindex $inoutchannels 0] ne "stdin" || [lindex $inoutchannels 1] ne "stdout"} {
return ""
}
set owner [punk::console::console_owner_get {stdin stdout}]
if {$owner eq "" || $owner eq [thread::id]} {
return ""
}
return $owner
}
punk::args::define {
@id -id ::punk::console::internal::console_share_qualifier
@cmd -name punk::console::internal::console_share_qualifier -summary\
@ -2809,7 +2914,14 @@ namespace eval punk::console {
proc ensure_object_integration {} {
variable object_integration_done
if {$object_integration_done} {
return
return 1
}
if {![namespace exists ::opunk::console]} {
#opunk::console not loaded - nothing to integrate with yet. In-module callers only
#reach here after a successful 'package require opunk::console'; external/manual
#callers may call speculatively. Deliberately does not set object_integration_done,
#so a later call after opunk::console loads performs the wiring.
return 0
}
set ::opunk::console::waiting_chunks_arrayvar ::punk::console::input_chunks_waiting
if {$::opunk::console::size_query_provider eq ""} {
@ -2820,8 +2932,27 @@ namespace eval punk::console {
#consult-time liveness validation still cover the default console there)
if {[info exists ::opunk::console::lifecycle_callback] && $::opunk::console::lifecycle_callback eq ""} {
set ::opunk::console::lifecycle_callback [list ::punk::console::internal::object_console_lifecycle]
#catch-up registration: anchors created in this interp before the callback was
#wired never fired a 'created' event, so their consoles have no recorded owner
#(seen when user code does 'package require opunk::console; opunk::console::create ...'
#before any punk::console object operation triggers this wiring). Anchors are
#per-interp/per-thread, so the anchoring context is this thread - register it.
#Only fills empty entries: an existing live registration is newer information than
#these pre-wiring anchors (and for the default console first-registration wins).
foreach v [info vars ::opunk::console::instances::*] {
if {![info exists $v]} {
continue
}
if {[catch {::opunk::Console::channels [set $v]} channels]} {
continue
}
if {[console_owner_get $channels] eq ""} {
console_owner_register $channels
}
}
}
set object_integration_done 1
return 1
}
lappend PUNKARGS [list {
@ -2892,12 +3023,19 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::move 2000 2000]
#flush before querying: the position query may execute in the console-owning
#thread (G-007 routing), whose flush acts on its own channel instance for the same
#OS handle - an unflushed move here would leave the terminal reporting the
#unmoved cursor position (seen as 'columns 1' on tcl 8.6 where the -winsize
#shortcut is unavailable and this mechanism actually runs)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::move $start_row $start_col][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::move $start_row $start_col]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -2931,12 +3069,16 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::cursor_save_dec][punk::ansi::move 2000 2000]
#flush before querying - see get_size_using_cursormove (G-007 routing: the query
#may flush a different channel instance in the console-owning thread)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::cursor_restore][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::cursor_restore_dec]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -4186,6 +4328,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before each cursor query: the query may execute in the console-owning thread
#(G-007 routing) whose flush acts on its own channel instance - unflushed emissions
#here would be measured as if they never happened (see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -4207,6 +4353,7 @@ namespace eval punk::console {
} errM]} {
puts stderr "test_char_width couldn't emit this string - \nerror: $errM"
}
flush $out ;#the test emission must reach the terminal before the (possibly routed) query measures it
set response [punk::console::get_cursor_pos $inoutchannels]
lassign [split $response ";"] _row2 col2
@ -4261,6 +4408,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before the cursor query - the alt-screen/move/erase emissions above must reach
#the terminal first (the query may flush a different channel instance in the
#console-owning thread under G-007 routing - see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -6202,7 +6353,7 @@ namespace eval ::punk::args::register {
## Ready
package provide punk::console [namespace eval punk::console {
variable version
set version 0.6.0
set version 0.7.1
}]
return

51
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/repl-0.2.1.tm → src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/repl-0.2.2.tm

@ -1635,6 +1635,7 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
#note -inputmode not available in Tcl 8.6 for chan configure!
#According to DKF - -buffering option doesn't affect input channels
set rawmode 0
set waiting_needs_reader 0 ;#set when the 8.6-console waiting-chunks path skips its read on a drained channel - the readable handler must be armed (not the after-idle reinvoke) or the pending partial line could never complete
set original_input_conf [chan configure $inputchan] ;#whether repl is in line or raw mode - we restore the inputchan (stdin) state
if {[dict exists $original_input_conf -inputmode]} {
@ -1693,9 +1694,46 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set chunk [read $inputchan]
set hostage_noread 0
if {"windows" eq $::tcl_platform(platform)
&& ![dict exists $original_input_conf -inputmode]
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]} {
#tcl 8.6 windows console channel (no -inputmode configure key, real console
#handle). On the 8.6 console driver a read on a *drained* channel parks a
#blocking cooked-mode ReadConsole that a later raw-mode flip cannot cancel:
#it swallows terminal query responses until Enter (see punk::console 0.7.1
#buildversion notes). This waiting-chunks path is normally entered via
#'after idle' with the channel drained - only consume what is already in the
#Tcl channel buffer (sized read - no driver probe); when nothing is buffered,
#process the stashed complete lines without reading and arm the readable
#handler for the rest.
set avail [chan pending input $inputchan]
if {$avail > 0} {
set chunk [read $inputchan $avail]
} else {
set chunk ""
set hostage_noread 1
set waiting_needs_reader 1
}
} else {
set chunk [read $inputchan]
}
set chunksize [string length $chunk]
if {$chunksize > 0} {
if {$hostage_noread} {
#nothing in the Tcl buffer and we must not probe the drained driver.
#Process the complete stashed lines now; a remaining partial line can only
#progress via more user input, which the armed readable handler delivers.
if {[llength $stdinlines]} {
punk::repl::repl_handler_restorechannel_if_not_eof $inputchan $original_input_conf
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
}
if {$waitingchunk ne ""} {
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set input_chunks_waiting($inputchan) [list]
}
} elseif {$chunksize > 0} {
if {[string index $chunk end] eq "\n"} {
lappend stdinlines $waitingchunk[string range $chunk 0 end-1]
#punk::console::cursorsave_move_emitblock_return 30 30 "repl_handler num_stdinlines [llength $stdinlines] chunk:$yellow[ansistring VIEW -lf 1 $chunk][a] fblocked:[fblocked $inputchan] pending:[chan pending input stdin]"
@ -1864,7 +1902,12 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
##################################################################################
#Re-enable channel read handler only if no waiting chunks - must process in order
##################################################################################
if {![llength $input_chunks_waiting($inputchan)]} {
if {![llength $input_chunks_waiting($inputchan)] || $waiting_needs_reader} {
#waiting_needs_reader: the 8.6-console waiting-chunks path found nothing in the
#Tcl channel buffer and must not probe the drained driver - the waiting data is
#an incomplete line that can only progress when the user types more, so arm the
#reader (the driver listening for input is its job here) instead of an after-idle
#reinvoke that could never make progress.
chan event $inputchan readable [list ::repl::repl_handler $inputchan $readmore $prompt_config]
} else {
#review
@ -4172,7 +4215,7 @@ namespace eval repl {
}
package provide punk::repl [namespace eval punk::repl {
variable version
set version 0.2.1
set version 0.2.2
}]
#repl::start $program_read_stdin_pipe

214
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punkboot/utils-0.1.1.tm

@ -0,0 +1,214 @@
# -*- tcl -*-
# Maintenance Instruction: leave the 999999.xxx.x as is and use 'pmix make' or src/make.tcl to update from <pkg>-buildversion.txt
#
# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem.
# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository.
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
# (C) 2023
#
# @@ Meta Begin
# Application punkboot::utils 0.1.1
# Meta platform tcl
# Meta license BSD
# @@ Meta End
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
## Requirements
##e.g package require frobz
package require Tcl 8.6-
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
namespace eval punkboot::utils {
variable version 0.1.0
namespace export parse_punkproject_version read_punkproject_version read_changelog_latest_version vcs_dirty_warnings
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::parse_punkproject_version
@cmd -name "::punkboot::utils::parse_punkproject_version"\
-summary\
"Parse the [project] version from TOML content string"\
-help\
"Scans TOML content for the [project] section and returns
the value of the version key as a string. Returns an empty
string if the section or key is not found."
@leaders
content -type string -optional 0 -help\
"TOML content string to parse"
}]
}
proc parse_punkproject_version {content} {
set in_project 0
foreach line [split $content \n] {
set trimmed [string trim $line]
if {[string index $trimmed 0] eq {[} && [string index $trimmed end] eq {]}} {
set in_project [expr {$trimmed eq "\[project\]"}]
continue
}
if {$in_project && [regexp {^version\s*=\s*"([^"]+)"} $trimmed _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_punkproject_version
@cmd -name "::punkboot::utils::read_punkproject_version"\
-summary\
"Read the [project] version from a TOML file"\
-help\
"Reads the file at tomlfile and returns the [project]
version value as a string. Returns an empty string if the
file does not exist or the version is not found."
@leaders
tomlfile -type string -optional 0 -help\
"Path to the TOML file to read"
}]
}
proc read_punkproject_version {tomlfile} {
if {![file exists $tomlfile]} {return ""}
set fh [open $tomlfile r]
try {
set content [read $fh]
} finally {
close $fh
}
return [::punkboot::utils::parse_punkproject_version $content]
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::read_changelog_latest_version
@cmd -name "::punkboot::utils::read_changelog_latest_version"\
-summary\
"Read the latest ## [X.Y.Z] version header from a changelog file"\
-help\
"Scans the changelog file from the top and returns the
version string from the first '## [X.Y.Z]' header line.
Returns an empty string if the file does not exist or no
matching header is found."
@leaders
changelogfile -type string -optional 0 -help\
"Path to the changelog file to read"
}]
}
proc read_changelog_latest_version {changelogfile} {
if {![file exists $changelogfile]} {return ""}
set fh [open $changelogfile r]
try {
set content [read $fh]
} finally {
close $fh
}
foreach line [split $content \n] {
if {[regexp {^##\s*\[([0-9][^\]]*)\]} $line _ v]} {
return $v
}
}
return ""
}
namespace eval argdoc {
lappend PUNKARGS [list {
@id -id ::punkboot::utils::vcs_dirty_warnings
@cmd -name "::punkboot::utils::vcs_dirty_warnings"\
-summary\
"Warning lines for uncommitted VCS changes above a path"\
-help\
"Walks up from path to the nearest fossil and/or git root and
returns warning lines describing uncommitted changes.
Artifacts built from a dirty source tree have no committed
provenance - callers such as make.tcl vendorupdate use this
to flag source projects that should be committed first.
checkedrootsvar names a dict variable in the caller's scope
used to report each VCS root at most once across calls.
label, if supplied, is prefixed into each warning to name
the calling operation (e.g. 'vendorupdate').
Returns an empty list if the path is clean, unversioned,
missing, already reported, or state cannot be determined."
@leaders
path -type string -optional 0 -help\
"Path to check; the nearest enclosing fossil/git root is examined"
checkedrootsvar -type string -optional 0 -help\
"Name of a dict variable in the caller's scope for dedupe across calls"
label -type string -optional 1 -default "" -help\
"Operation name prefixed into warnings"
}]
}
proc vcs_dirty_warnings {path checkedrootsvar {label ""}} {
upvar 1 $checkedrootsvar checkedroots
if {![info exists checkedroots]} {set checkedroots [dict create]}
if {$label ne ""} {set label "$label "}
set warnings [list]
set dir [file normalize $path]
if {![file isdirectory $dir]} {
return $warnings ;#missing path - not this proc's business to report
}
set fossilroot ""
set gitroot ""
while {1} {
if {$fossilroot eq "" && ([file exists [file join $dir .fslckout]] || [file exists [file join $dir _FOSSIL_]])} {
set fossilroot $dir
}
if {$gitroot eq "" && [file exists [file join $dir .git]]} {
set gitroot $dir
}
if {$fossilroot ne "" || $gitroot ne ""} {
break ;#report at the nearest VCS root(s); a dual git+fossil root is caught in one pass
}
set parent [file dirname $dir]
if {$parent eq $dir} { break }
set dir $parent
}
if {$fossilroot ne "" && ![dict exists $checkedroots fossil,$fossilroot] && [llength [auto_execok fossil]]} {
dict set checkedroots fossil,$fossilroot 1
#fossil changes must run from within the checkout
set original_cwd [pwd]
if {[catch {
cd $fossilroot
set fchanges [string trim [exec {*}[auto_execok fossil] changes]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine fossil state of source project at $fossilroot ($errM)"
} elseif {$fchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $fossilroot has uncommitted fossil changes ([llength [split $fchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
cd $original_cwd
}
if {$gitroot ne "" && ![dict exists $checkedroots git,$gitroot] && [llength [auto_execok git]]} {
dict set checkedroots git,$gitroot 1
if {[catch {
set gchanges [string trim [exec {*}[auto_execok git] -C $gitroot status --porcelain]]
} errM]} {
lappend warnings "WARNING: ${label}could not determine git state of source project at $gitroot ($errM)"
} elseif {$gchanges ne ""} {
lappend warnings "WARNING: ${label}source project at $gitroot has uncommitted git changes ([llength [split $gchanges \n]] file(s)) - artifacts built from a dirty tree have no committed provenance"
}
}
return $warnings
}
}
namespace eval ::punk::args::register {
#use fully qualified so 8.6 doesn't find existing var in global namespace
lappend ::punk::args::register::NAMESPACES ::punkboot::utils ::punkboot::utils::argdoc
}
## Ready
package provide punkboot::utils [tcl::namespace::eval punkboot::utils {
variable version
#- this version number, exactly 0.1.1, is a literal used in src module folders
#- we refer to this sometimes as the magic version number
set version 0.1.1
}]
return

42
src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/shellfilter-0.2.2.tm → src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/shellfilter-0.2.3.tm

@ -2849,9 +2849,19 @@ namespace eval shellfilter {
set id_out [shellfilter::stack::add $outchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_out -pipechan $outpipechan]]
set id_err [shellfilter::stack::add $errchan tee_to_pipe -action sink-aside -settings [list -tag $teehandle_err -pipechan $errpipechan]]
#From here to the stack::remove calls the caller's channels are diverted:
#any error escaping this region would leave stdout/stderr broken for the rest of the
#process with all subsequent output silently lost - hence the try/finally below, and
#catch-guards on decorative logging (stack-status table rendering runs punk::ansi/textblock
#code that can fail e.g when mismatched module snapshots are loaded - a rendering failure
#must degrade to a log note, never abort the run).
log::critical {
::shellfilter::log::write $runtag "[punk::ansi::ansistrip [shellfilter::stack status]]\nchan names:[chan names]"
if {[catch {punk::ansi::ansistrip [shellfilter::stack status]} stackstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $stackstatus)"
}
::shellfilter::log::write $runtag "$stackstatus\nchan names:[chan names]"
}
try {
# need to use os level channel handle for stdin - try named pipes (or even sockets) instead of fifo2 for this
# If non os-level channel - the command can't be run with the redirection
# stderr/stdout can be run with non-os handles in the call -
@ -2919,22 +2929,28 @@ namespace eval shellfilter {
#----------------------------------------------------------------------------------------------
#todo - change to log::debug
log::critical {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
if {[catch {
if {![catch {package require punk::ansi}]} {
set stackstatus [punk::ansi::ansistrip [shellfilter::stack status]]
} else {
set stackstatus [shellfilter::stack status]
}
} errstatus]} {
set stackstatus "(shellfilter::stack status unavailable: $errstatus)"
}
::shellfilter::log::write $runtag "shellfilter::stack status after execution: \n$stackstatus\nchan:names [chan names]"
}
#----------------------------------------------------------------------------------------------
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
} finally {
#the previous redirections on the underlying inchan/outchan/errchan items will be restored from the -aside setting during removal
#Remove execution-time Tees from stack - must happen even if the body above errored,
#or the caller's stdout/stderr stay diverted and all subsequent process output is silently lost.
log::debug {::shellfilter::log::write $runtag "removing $id_out from stdout stack"}
shellfilter::stack::remove $outchan $id_out
log::debug {::shellfilter::log::write $runtag "removing $id_err from stderr stack"}
shellfilter::stack::remove $errchan $id_err
}
log::info {::shellfilter::log::write $runtag " return '$exitinfo'"}
@ -3811,5 +3827,5 @@ namespace eval shellfilter {
package provide shellfilter [namespace eval shellfilter {
variable version
set version 0.2.2
set version 0.2.3
}]

421
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/shellrun-0.1.2.tm → src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/shellrun-0.1.4.tm

@ -5,6 +5,7 @@
package require shellfilter
package require punk::ansi
package require punk::args
#NOTE: the run,runout,runerr,runx commands only produce an error if the command didn't run.
# - If it did run, but there was a non-zero exitcode it is up to the application to check that.
@ -79,76 +80,45 @@ namespace eval shellrun {
#maintenance: similar used in punk::ns & punk::winrun
#todo - take runopts + aliases as args
#longopts must be passed as a single item ie --timeout=100 not --timeout 100
proc get_run_opts {arglist} {
if {[catch {
set callerinfo [info level -1]
} errM]} {
set caller ""
} else {
set caller [lindex $callerinfo 0]
}
#we provide -nonewline even for 'run' even though run doesn't deliver stderr or stdout to the tcl return value
#This is for compatibility with other runX commands, and the difference is also visible when calling from repl.
set known_runopts [list "-echo" "-e" "-nonewline" "-n" "-tcl" "-debug"]
set known_longopts [list "--timeout"]
set known_longopts_msg ""
foreach lng $known_longopts {
append known_longopts_msg "${lng}=val "
}
set aliases [list "-e" "-echo" "-echo" "-echo" "-n" "-nonewline" "-nonewline" "-nonewline" "-tcl" "-tcl" "-debug" "-debug"] ;#include map to self
set runopts [list]
set runoptslong [list]
set cmdargs [list]
set idx_first_cmdarg [lsearch -not $arglist "-*"]
set allopts [lrange $arglist 0 $idx_first_cmdarg-1]
set cmdargs [lrange $arglist $idx_first_cmdarg end]
foreach o $allopts {
if {[string match --* $o]} {
lassign [split $o =] flagpart valpart
if {$valpart eq ""} {
error "$caller: longopt $o seems to be missing a value - must be of form --option=value"
}
if {$flagpart ni $known_longopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runoptslong $flagpart $valpart
} else {
if {$o ni $known_runopts} {
error "$caller: Unknown runoption $o - known options $known_runopts $known_longopts_msg"
}
lappend runopts [dict get $aliases $o]
}
}
return [list runopts $runopts runoptslong $runoptslong cmdargs $cmdargs]
}
#todo - investigate cause of punk86 run hanging sometimes. An 'after 500' before exit in the called script fixes the issue. punk87 doesn't seem to be affected.
lappend PUNKARGS [list {
@id -id ::shellrun::run
@cmd -name "shellrun::run"\
-summary "Run an external command, streaming stdout/stderr to the console in real-time"\
-help\
"Run an external command and return the shellfilter exitinfo dict.
stdout and stderr are written to the console as they arrive;
nothing is captured for the return value. The exitcode is
part of the returned dict — a non-zero exitcode is NOT raised
as a Tcl error, the caller must check it explicitly.
Use -tcl to run a Tcl script/command instead of an external
process; in that case the returned dict carries a 'result'
key (or error/errorCode/errorInfo on failure) instead of an
exitcode."
@leaders -min 0 -max 0
@opts
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-nonewline -type none -help\
"Accepted for compatibility with runout/runerr/runx. Has no
effect on run because run does not capture or return stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc run {args} {
#set_last_run_display [list]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set runoptslong [dict get $splitargs runoptslong]
#set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::run]
lassign [dict values $argd] leaders opts values received
@ -206,11 +176,25 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runconsole
@cmd -name "shellrun::runconsole"\
-summary "Run a command via exec with stdout/stderr directed to the console, mimicking Tcl's unknown handler"\
-help\
"Resolve cmdname via auto_execok and exec it with stdout
and stderr redirected to the active console. The result is
returned via uplevel with the exec result/options dict,
mirroring the way Tcl's unknown proc handles external
commands.
Unlike run, this uses exec semantics — there is no
exitcode in the return value and stdout/stderr are not
captured separately."
@leaders -min 0 -max 0
@opts
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the command to resolve via auto_execok and run."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed to the resolved command."
}]
#run in the way tcl unknown does - but without regard to auto_noexec
proc runconsole {args} {
@ -256,16 +240,42 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runout
@cmd -name "shellrun::runout"\
-summary "Run an external command and return its stdout"\
-help\
"Run an external command, capture stdout and stderr, and
return stdout as the result. stderr and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stdout.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runout {args} {
set argd [punk::args::parse $args withid ::shellrun::runout]
@ -284,11 +294,6 @@ namespace eval shellrun {
set runerr ""
set RST [a]
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
#puts stdout "RUNOUT cmdargs: $cmdargs"
#todo add -data boolean and -data lastwrite to -settings with default being -data all
@ -422,16 +427,42 @@ namespace eval shellrun {
lappend PUNKARGS [list {
@id -id ::shellrun::runerr
@cmd -name "shellrun::runerr"\
-summary "Run an external command and return its stderr"\
-help\
"Run an external command, capture stdout and stderr, and
return stderr as the result. stdout and the exitcode are
emitted via repl telemetry chunks but are not part of the
return value.
By default no console output is produced while the command
runs; use -echo to tee stdout and stderr to the console in
real-time. Use -nonewline to strip a trailing newline from
the returned stderr.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none
-nonewline -type none
-tcl -type none -default 0
-debug -type none -default 0
--timeout= -type integer
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string
cmdarg -type any -multiple 1 -optional 1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runerr {args} {
set argd [punk::args::parse $args withid ::shellrun::runerr]
@ -449,10 +480,6 @@ namespace eval shellrun {
set runout ""
set runerr ""
#set splitargs [get_run_opts $args]
#set runopts [dict get $splitargs runopts]
#set cmdargs [dict get $splitargs cmdargs]
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
@ -567,6 +594,43 @@ namespace eval shellrun {
}
lappend PUNKARGS [list {
@id -id ::shellrun::runx
@cmd -name "shellrun::runx"\
-summary "Run an external command and return a dict of stdout, stderr, and exitcode"\
-help\
"Run an external command, capture stdout and stderr, and
return a dict containing stdout, stderr, and exitcode (or
result/error/errorCode/errorInfo for -tcl calls).
No console output is produced unless -echo is given. Use
-nonewline to strip trailing newlines from captured stdout
and stderr before returning.
Use -tcl to run a Tcl script/command instead of an external
process."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-tcl -type none -default 0 -help\
"Interpret cmdname (and cmdarg) as a Tcl command/script rather
than an external process."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdname -type string -help\
"Name of the external command (or Tcl command when -tcl is
given)."
cmdarg -type any -multiple 1 -optional 1 -help\
"Additional arguments passed through to the command."
}]
proc runx {args} {
#set_last_run_display [list]
variable runout
@ -574,11 +638,10 @@ namespace eval shellrun {
set runout ""
set runerr ""
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::runx]
lassign [dict values $argd] leaders opts values received
if {"-nonewline" in $runopts} {
if {[dict exists $received "-nonewline"]} {
set nonewline 1
} else {
set nonewline 0
@ -586,7 +649,7 @@ namespace eval shellrun {
#shellfilter::stack::remove stdout $::repl::id_outstack
if {"-echo" in $runopts} {
if {[dict exists $received "-echo"]} {
#float to ensure repl transform doesn't interfere with the output data
set stderr_stackid [shellfilter::stack::add stderr tee_to_var -action float -settings {-varname ::shellrun::runerr}]
set stdout_stackid [shellfilter::stack::add stdout tee_to_var -action float -settings {-varname ::shellrun::runout}]
@ -601,10 +664,23 @@ namespace eval shellrun {
set stdout_stackid [shellfilter::stack::add stdout var -action float-locked -junction 1 -settings {-varname ::shellrun::runout}]
}
set callopts ""
if {"-tcl" in $runopts} {
append callopts " -tclscript 1"
set callopts [dict create]
if {[dict exists $received "-tcl"]} {
dict set callopts -tclscript 1
}
if {[dict exists $received "-debug"]} {
dict set callopts -debug 1
}
if {[dict exists $received --timeout]} {
dict set callopts -timeout [dict get $opts --timeout]
}
set cmdname [dict get $values cmdname]
if {[dict exists $received cmdarg]} {
set cmdarglist [dict get $values cmdarg]
} else {
set cmdarglist {}
}
set cmdargs [concat $cmdname $cmdarglist]
#set exitinfo [shellfilter::run $cmdargs -teehandle punksh -inbuffering none -outbuffering none -stdinhandler ::repl::repl_handler]
set exitinfo [shellfilter::run $cmdargs {*}$callopts -teehandle punksh -inbuffering none -outbuffering none]
@ -616,7 +692,7 @@ namespace eval shellrun {
flush stdout
if {[dict exists $exitinfo error]} {
if {"-tcl" in $runopts} {
if {[dict exists $received "-tcl"]} {
} else {
#todo - check errorInfo makes sense.. return -code? tailcall?
@ -826,30 +902,161 @@ namespace eval shellrun {
return $exitinfo
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_run
@cmd -name "shellrun::sh_run"\
-summary "Run a command line via sh -c, streaming stdout/stderr to the console"\
-help\
"Wrapper around shellrun::run that invokes the command line
via sh -c. The entire command line after options is passed
as a single argument to sh -c.
Accepts the same options as shellrun::run except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-nonewline -type none -help\
"Accepted for compatibility. Has no effect on run-based
commands because run does not capture output."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_run {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_run]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
#e.g sh -c "ls -l *"
#we pass cmdargs to sh -c as a list, not individually
tailcall shellrun::run {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runout
@cmd -name "shellrun::sh_runout"\
-summary "Run a command line via sh -c and return its stdout"\
-help\
"Wrapper around shellrun::runout that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runout except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stdout."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runout {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runout]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runout {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runerr
@cmd -name "shellrun::sh_runerr"\
-summary "Run a command line via sh -c and return its stderr"\
-help\
"Wrapper around shellrun::runerr that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runerr except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return value."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from the returned
stderr."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runerr {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runerr]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runerr {*}$runopts sh -c $cmdargs
}
lappend PUNKARGS [list {
@id -id ::shellrun::sh_runx
@cmd -name "shellrun::sh_runx"\
-summary "Run a command line via sh -c and return a dict of stdout, stderr, and exitcode"\
-help\
"Wrapper around shellrun::runx that invokes the command
line via sh -c. The entire command line after options is
passed as a single argument to sh -c.
Accepts the same options as shellrun::runx except -tcl,
which is not meaningful for the sh_* wrappers because they
always invoke an external shell."
@leaders -min 0 -max 0
@opts
-echo -type none -help\
"Tee stdout and stderr to the console in real-time while still
capturing them for the return dict."
-nonewline -type none -help\
"Strip a trailing newline (\\r\\n or \\n) from captured stdout
and stderr in the returned dict."
-debug -type none -default 0 -help\
"Enable debug diagnostics from shellfilter::run."
--timeout= -type integer -help\
"Timeout in milliseconds for the external process."
@values -min 1 -max -1
cmdargs -type any -multiple 1 -help\
"Command line to pass to sh -c as a single string argument."
}]
proc sh_runx {args} {
set splitargs [get_run_opts $args]
set runopts [dict get $splitargs runopts]
set cmdargs [dict get $splitargs cmdargs]
set argd [punk::args::parse $args withid ::shellrun::sh_runx]
lassign [dict values $argd] leaders opts values received
set cmdargs [dict get $values cmdargs]
set runopts [list]
if {[dict exists $received -echo]} { lappend runopts -echo }
if {[dict exists $received -nonewline]} { lappend runopts -nonewline }
if {[dict exists $received -debug]} { lappend runopts -debug }
if {[dict exists $received --timeout]} { lappend runopts --timeout=[dict get $opts --timeout] }
tailcall shellrun::runx {*}$runopts sh -c $cmdargs
}
}
@ -895,5 +1102,5 @@ namespace eval ::punk::args::register {
package provide shellrun [namespace eval shellrun {
variable version
set version 0.1.2
set version 0.1.4
}]

106
src/project_layouts/custom/_project/punk.shell-0.1/src/make.tcl

@ -22,7 +22,7 @@ namespace eval ::punkboot {
variable pkg_requirements [list]; variable pkg_missing [list];variable pkg_loaded [list]
variable non_help_flags [list -k]
variable help_flags [list -help --help /? -h]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate ]
variable known_commands [list project modules libs packages vfs bin info check shell vendorupdate bootsupport vfscommonupdate projectversion]
}
@ -1358,6 +1358,8 @@ proc ::punkboot::punkboot_gethelp {args} {
append h " - show module/library paths and any potentially problematic packages for running this script" \n
append h " $scriptname shell" \n
append h " - run the punk shell using bootsupport libraries." \n
append h " $scriptname projectversion" \n
append h " - advisory check: verify CHANGELOG.md matches punkproject.toml and warn if src/ has changes since the last project-version bump." \n \n
append h "" \n
if {[llength [dict get $pkg_availability missing]] || [llength [dict get $pkg_availability broken]]} {
set has_recommended 0
@ -1532,7 +1534,7 @@ if {[lsearch $::argv -k] >= 0} {
# - minor bump -> prompt user; backward-compat aliases *should* keep old bootsupport functional
# - patch bump -> warn and proceed (non-breaking)
# 'check' is exempt in all cases the warning is shown at the end of its output instead.
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check"} {
if {[info exists ::punkboot::stale_bootsupport] && $::punkboot::command ne "check" && $::punkboot::command ne "projectversion"} {
set _stale $::punkboot::stale_bootsupport
set _have_major 0
set _have_minor 0
@ -1825,8 +1827,88 @@ if {$::punkboot::command eq "info"} {
exit 0
}
if {$::punkboot::command eq "projectversion"} {
package require punkboot::utils
set sep [string repeat - 75]
puts stdout $sep
puts stdout "project version check"
puts stdout $sep
set pp_file [file join $projectroot punkproject.toml]
set cl_file [file join $projectroot CHANGELOG.md]
set src_folder [file join $projectroot src]
set pp_version [::punkboot::utils::read_punkproject_version $pp_file]
set cl_version [::punkboot::utils::read_changelog_latest_version $cl_file]
puts stdout "punkproject.toml version : [expr {[string length $pp_version] ? $pp_version : "(not found)"}]"
puts stdout "CHANGELOG.md version : [expr {[string length $cl_version] ? $cl_version : "(not found)"}]"
# 1. Consistency: CHANGELOG latest header must match punkproject.toml version.
if {![string length $pp_version]} {
puts stderr "WARNING: could not read version from $pp_file"
} elseif {![string length $cl_version]} {
puts stderr "WARNING: no '## [X.Y.Z]' version header found in $cl_file"
} elseif {$pp_version ne $cl_version} {
puts stderr "WARNING: version mismatch punkproject.toml=$pp_version but CHANGELOG.md=$cl_version"
puts stderr " The latest '## [X.Y.Z]' header in CHANGELOG.md must match the version in punkproject.toml."
} else {
puts stdout "consistency: OK (versions match)"
}
# 2. Staleness: warn if src/ has git commits since the last commit
# touching punkproject.toml (version bump may be overdue).
# If punkproject.toml has an uncommitted version change, the bump is
# already in progress report that only a commit is needed.
# Best-effort silently skipped if git is unavailable or the repo
# is not under git revision control.
set pp_commit ""
if {![catch {exec git -C $projectroot log -1 --format=%H -- punkproject.toml} pp_raw]} {
set pp_commit [string trim $pp_raw]
}
if {[string length $pp_commit]} {
if {![catch {exec git -C $projectroot log --oneline ${pp_commit}..HEAD -- src/} src_raw]} {
set src_changes [string trim $src_raw]
if {[string length $src_changes]} {
set n [llength [split $src_changes \n]]
# Check whether a bump is already staged in the working tree
# (punkproject.toml modified but uncommitted). If the working-
# tree version differs from HEAD, the bump is done only a
# commit is needed.
set head_version ""
if {![catch {exec git -C $projectroot show HEAD:punkproject.toml} head_raw]} {
set head_version [::punkboot::utils::parse_punkproject_version $head_raw]
}
set bump_uncommitted [expr {[string length $head_version] && [string length $pp_version] && $pp_version ne $head_version}]
if {$bump_uncommitted} {
puts stderr "NOTE: $n commit(s) in src/ since last punkproject.toml commit, but punkproject.toml already bumped ($head_version -> $pp_version, uncommitted)."
puts stderr " Commit punkproject.toml (and CHANGELOG.md) to record the bump and clear this warning."
} else {
puts stderr "WARNING: $n commit(s) in src/ since last punkproject.toml change a project-version bump may be overdue."
puts stderr " Review the changes and bump punkproject.toml per the root AGENTS.md 'Project Versioning' section."
}
puts stderr " (showing first 5 commits below)"
set i 0
foreach line [split $src_changes \n] {
if {$i >= 5} break
puts stderr " $line"
incr i
}
} else {
puts stdout "staleness: OK (no src/ changes since last punkproject.toml commit)"
}
} else {
puts stdout "staleness: skipped (git query failed)"
}
} else {
puts stdout "staleness: skipped (punkproject.toml has no git history or git unavailable)"
}
puts stdout $sep
exit 0
}
if {$::punkboot::command eq "shell"} {
package require struct::list
@ -1890,10 +1972,20 @@ if {$::punkboot::command eq "vendorupdate"} {
#puts "-- [tcl::tm::list] --"
puts stdout "Updating vendor modules in src folder"
#dirty-checkout provenance warnings via punkboot::utils::vcs_dirty_warnings (loaded from bootsupport).
#Guarded require: vendorupdate must keep working even if the bootsupport punkboot::utils copy is
#missing or stale (it may be the very thing being repaired) - the check degrades to skipped.
set have_vcs_dirty_check [expr {![catch {package require punkboot::utils}] && [llength [info commands ::punkboot::utils::vcs_dirty_warnings]]}]
if {!$have_vcs_dirty_check} {
puts stderr "WARNING: vendorupdate dirty-checkout provenance check unavailable (punkboot::utils vcs_dirty_warnings not loadable from bootsupport) - continuing without it"
}
proc vendor_localupdate {projectroot} {
set local_modules [list]
set git_modules [list]
set fossil_modules [list]
set checked_source_paths [dict create] ;#skip repeat walk-ups for identical source paths
set checked_vcs_roots [dict create] ;#report each VCS root at most once per run
set sourcefolder $projectroot/src
#todo vendor/lib
set vendorlibfolders [glob -nocomplain -dir $sourcefolder -type d -tails vendorlib_tcl*]
@ -1939,6 +2031,16 @@ if {$::punkboot::command eq "vendorupdate"} {
} else {
set srclocation [file join $localpath $module_subpath]
}
#dirty-checkout provenance check - each VCS root reported at most once per run
if {$::have_vcs_dirty_check} {
set normsrclocation [file normalize $srclocation]
if {![dict exists $checked_source_paths $normsrclocation]} {
dict set checked_source_paths $normsrclocation 1
foreach w [::punkboot::utils::vcs_dirty_warnings $normsrclocation checked_vcs_roots vendorupdate] {
puts stderr $w
}
}
}
#puts stdout "$relpath $module $module_subpath $srclocation"
#todo - check if requested_module has version extension and allow explicit versions instead of just latest

3
src/project_layouts/vendor/punk/project-0.1/bin/README.md vendored

@ -0,0 +1,3 @@
Project scripts and executables
These files usually shouldn't be modified directly as they are built from the files in the src folder

26
src/project_layouts/vendor/punk/project-0.1/bin/runtime/README.md vendored

@ -0,0 +1,26 @@
runtime Tcl Executables are installed here under the appropriate os_architecture folder.
e.g
windows 64bit binaries under win32-x86_64
linux 64 bit binaries under linux-x86_64
The bin/runtime.cmd cross-platform script can be used to download prebuilt Tcl binaries.
e.g ./bin/runtime.cmd fetch
This will detect the platform and architecture, creating the containing folder as necessary.
To see existing installed runtimes:
./bin/runtime.cmd list
Or to compare local runtimes with those available on the artifact server:
./bin/runtime.cmd list -remote
An installed runtime can then be used to call the src/make.tcl file.
e.g - initial check
./bin/runtime.cmd run src/make.tcl check
e.g - to build (or just copy with latest version number from src if build not required) modules to projectdir/modules
./bin/runtime.cmd run src/make.tcl modules
for libs & modules
./bin/runtime.cmd run src/make.tcl packages

6
src/project_layouts/vendor/punk/project-0.1/lib/README.md vendored

@ -0,0 +1,6 @@
Tcl Libraries which use the pkgIndex.tcl system to load.
Each library will usually have its own folder in the lib folder.
Some groups of libraries may have a deeper structure - usually with a pkIndex.tcl at the top level,
and individual pkgIndex.tcl files further down.
These files shouldn't be modified directly as they are built or copied from the files in the src folder

9
src/project_layouts/vendor/punk/project-0.1/modules/README.md vendored

@ -0,0 +1,9 @@
Tcl Modules
These are packaged as .tm file extension and the loadable locations are controlled by functions such as tcl::tm::path add|list|remove
Via the make process, the modules may come from from src/vendormodules - being modules not generally developed within the current project.
They are built from src/modules for those that are directly part of the project.
These files shouldn't be modified directly as they are built or copied from the files in the src folder structure.

2
src/tests/modules/AGENTS.md

@ -38,5 +38,5 @@ Unit tests for editable source modules under `src/modules/`, `src/modules_tcl8/`
## Child DOX Index
- `punkcheck/` — punkcheck module tests (install, summarize_install_resultdict, installtrack)
- `punk/mix/` — punk::mix::cli tests (prune helpers, punkcheck virtual sources)
- `punk/mix/` — punk::mix::cli tests (prune helpers, punkcheck virtual sources) and punk::mix::commandset::repo fossil move/rename characterization tests (`testsuites/repo/`, FOSSIL_HOME-isolated; GAP-marked tests pin behaviour G-022 will change)

97
src/tests/modules/punk/console/testsuites/console/objectintegration.test

@ -0,0 +1,97 @@
package require tcltest
tcltest::configure {*}$::argv
#min-version bound documents that these tests target the dev module's API and protects
#against stable bootsupport copies shadowing the alpha dev version if this file is sourced outside
#runtests.tcl (whose testinterp runs 'package prefer latest' making the bound redundant there).
package require punk::console 999999.0a1.0-
#Tests for punk::console::ensure_object_integration wiring-order behaviour: the graceful no-op
#when opunk::console is not loaded, and catch-up ownership registration for anchors created
#before the lifecycle callback was wired.
#ORDER MATTERS within this file: the first test relies on opunk::console not yet being loaded
#in this interp (runtests gives each test file a fresh testinterp), and the retro-registration
#test relies on creating its anchor before the first successful ensure_object_integration.
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
tcltest::testConstraint opunk_not_loaded [expr {[catch {package present opunk::console}]}]
test ensure_object_integration_no_opunk {graceful no-op (0, no error, no latch) when opunk::console is not loaded}\
-constraints opunk_not_loaded\
-setup $common -body {
lappend result [punk::console::ensure_object_integration]
#must not have latched - a later call after opunk::console loads must still wire
lappend result [set ::punk::console::object_integration_done]
}\
-cleanup {}\
-result [list\
0\
0\
]
test ensure_object_integration_retro_registration {anchor created before wiring gains a registered owner when ensure_object_integration wires the callback}\
-setup $common -body {
package require opunk::console
lassign [chan pipe] rd wr
#create BEFORE integration wiring - lifecycle callback is empty so no 'created'
#event fires and no owner is registered (the wiring-order hole)
opunk::console::create preintegration $rd $wr
lappend result [punk::console::console_owner_get [list $rd $wr]]
#wiring performs the catch-up registration for the pre-existing anchor
lappend result [punk::console::ensure_object_integration]
lappend result [expr {[punk::console::console_owner_get [list $rd $wr]] eq [thread::id]}]
#lifecycle callback is now wired - forget clears the entry as usual
opunk::console::forget preintegration
lappend result [punk::console::console_owner_get [list $rd $wr]]
}\
-cleanup {
catch {opunk::console::forget preintegration}
catch {punk::console::console_owner_forget [list $rd $wr]}
catch {chan close $rd}
catch {chan close $wr}
}\
-result [list\
{}\
1\
1\
{}\
]
test ensure_object_integration_idempotent {second call is a latched no-op returning 1}\
-setup $common -body {
lappend result [punk::console::ensure_object_integration]
lappend result [set ::punk::console::object_integration_done]
}\
-cleanup {}\
-result [list\
1\
1\
]
test ensure_object_integration_create_after_wiring {anchor created after wiring registers via the lifecycle callback (no retro path involved)}\
-setup $common -body {
lassign [chan pipe] rd wr
opunk::console::create postintegration $rd $wr
lappend result [expr {[punk::console::console_owner_get [list $rd $wr]] eq [thread::id]}]
opunk::console::forget postintegration
lappend result [punk::console::console_owner_get [list $rd $wr]]
}\
-cleanup {
catch {opunk::console::forget postintegration}
catch {punk::console::console_owner_forget [list $rd $wr]}
catch {chan close $rd}
catch {chan close $wr}
}\
-result [list\
1\
{}\
]
}
tcltest::cleanupTests ;#needed to produce test summary.

125
src/tests/modules/punk/console/testsuites/console/ownerrouting.test

@ -0,0 +1,125 @@
package require tcltest
tcltest::configure {*}$::argv
#min-version bounds document that these tests target the dev modules' API and protect against
#stable copies shadowing them if this file is sourced outside runtests.tcl (whose testinterp
#runs 'package prefer latest').
package require punk::console 999999.0a1.0-
#Tests for G-007 choke-point brokering: a terminal query (internal::get_ansi_response_payload
#and every query proc layered above it) on the process-default console {stdin stdout} routes
#to the console-owning thread when the caller is not the owner; an unregistered console, an
#owner==caller console, and any non-default {in out} pair operate locally (non-std channel
#names are thread-local, so a pair spec always names the calling thread's own console).
#The routing decision is internal::console_route_owner; the transport is a synchronous
#thread::send into the owning thread's main interp (the established vt52-alias property:
#the owner services events while the caller blocks).
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
test routing_decision {console_route_owner: local for unregistered/self-owned/non-default/dead-owner; owner tid only for the default console owned by another live thread}\
-setup [string cat $common {
set workertid [thread::create {thread::wait}]
}]\
-body {
#unregistered default console - operate locally
lappend result [punk::console::internal::console_route_owner {stdin stdout}]
#caller is the owner - local fast path
punk::console::console_owner_register {stdin stdout}
lappend result [punk::console::internal::console_route_owner {stdin stdout}]
punk::console::console_owner_forget {stdin stdout}
#another live thread owns the default console - route to it
punk::console::console_owner_register {stdin stdout} $workertid
lappend result [expr {[punk::console::internal::console_route_owner {stdin stdout}] eq $workertid}]
#non-default pair never routes, even with a registry entry (the entry qualifies
#the shared fact store; the pair names the calling thread's own channels)
punk::console::console_owner_register {chanx chany} $workertid
lappend result [punk::console::internal::console_route_owner {chanx chany}]
punk::console::console_owner_forget {chanx chany}
#dead owner - consult-time liveness validation clears the entry, operate locally
thread::release $workertid
set deadline [expr {[clock milliseconds] + 2000}]
while {[thread::exists $workertid] && [clock milliseconds] < $deadline} {
after 10
}
lappend result [punk::console::internal::console_route_owner {stdin stdout}]
}\
-cleanup {
catch {punk::console::console_owner_forget {stdin stdout}}
catch {punk::console::console_owner_forget {chanx chany}}
catch {thread::release $workertid}
}\
-result [list\
{}\
{}\
1\
{}\
{}\
]
test routing_query_forwarded_to_owner {a query on the owned default console executes in the owning thread with opts/values intact; query procs above the choke point inherit the routing; owner-side errors propagate with routing context}\
-setup [string cat $common {
set workertid [thread::create {thread::wait}]
#the worker loads punk::console from the same module paths as the testinterp
thread::send $workertid [list set ::tmpaths [tcl::tm::list]]
thread::send $workertid [list set ::auto_path $::auto_path]
thread::send $workertid {
foreach p $::tmpaths {tcl::tm::path add $p}
package prefer latest
package require punk::console 999999.0a1.0-
#replace the real query primitive with a recorder: no real terminal is
#available under runtests, and the transport - not the terminal I/O - is
#what this test exercises
rename ::punk::console::internal::get_ansi_response_payload ::punk::console::internal::get_ansi_response_payload.real
set ::routingtest_mode ok
proc ::punk::console::internal::get_ansi_response_payload {args} {
set ::routingtest_seen [list [thread::id] $args]
if {$::routingtest_mode eq "error"} {
error "recorder-failure"
}
return "5;7"
}
}
punk::console::console_owner_register {stdin stdout} $workertid
}]\
-body {
#direct call on the choke point routes and returns the owner-side result
set payload [punk::console::internal::get_ansi_response_payload -console {stdin stdout} -expected_ms 123 "\x1b\[6n" {(.*)(\x1b\[([0-9]+;[0-9]+)R)$}]
lappend result $payload
lassign [thread::send $workertid {set ::routingtest_seen}] seentid seenargs
#the call executed in the owning thread
lappend result [expr {$seentid eq $workertid}]
#opts and values arrived intact (values are the trailing pair; opts precede them)
lappend result [lrange $seenargs end-1 end]
set seenopts [lrange $seenargs 0 end-2]
lappend result [dict get $seenopts -console]
lappend result [dict get $seenopts -expected_ms]
#a query proc layered above the choke point inherits the routing (get_cursor_pos
#returns the payload unchanged)
lappend result [punk::console::get_cursor_pos]
#an error raised in the owning thread propagates to the caller with routing context
thread::send $workertid {set ::routingtest_mode error}
lappend result [expr {[catch {punk::console::internal::get_ansi_response_payload -console {stdin stdout} "\x1b\[6n" {(.*)(\x1b\[([0-9]+;[0-9]+)R)$}} errM]
&& [string match "*routed to console-owning thread*recorder-failure*" $errM]}]
}\
-cleanup {
catch {punk::console::console_owner_forget {stdin stdout}}
catch {thread::release $workertid}
}\
-result [list\
{5;7}\
1\
[list "\x1b\[6n" {(.*)(\x1b\[([0-9]+;[0-9]+)R)$}]\
{stdin stdout}\
123\
{5;7}\
1\
]
}
tcltest::cleanupTests ;#needed to produce test summary.

66
src/tests/modules/punk/console/testsuites/console/probes.test

@ -137,6 +137,72 @@ namespace eval ::testspace {
1\
]
#A real windows console is detectable via twapi even where the channel exposes no
#-inputmode configure key (tcl 8.6). The constraint shares the implementation's primitive,
#so this is a wiring regression test: it fails if the twapi branch is removed or ordered
#after the eof/env fallthroughs (which return 0 for a console stdin with hints cleared).
tcltest::testConstraint win_console_twapi [expr {
"windows" eq $::tcl_platform(platform)
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]
}]
test is_input_console_or_tty_stdin_windows_console {stdin backed by a real windows console is a console/tty regardless of -inputmode key availability (tcl 8.6 vs 9)}\
-constraints win_console_twapi\
-setup [string cat $common $hints_save]\
-body {
lappend result [punk::console::is_input_console_or_tty stdin]
}\
-cleanup $hints_restore\
-result [list\
1\
]
#The idle-reader hostage guard only applies where chan configure exposes no -inputmode
#key (tcl 8.6 windows console) - on 8.7/9 consoles this constraint self-skips.
tcltest::testConstraint win_console_86_cooked [expr {
"windows" eq $::tcl_platform(platform)
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]
&& ![dict exists [chan configure stdin] -inputmode]
&& ![tsv::get punk_console is_raw]
}]
test query_hostage_guard_armed_reader {a terminal query on a cooked 8.6 console with an armed stdin readable handler fails fast without emitting (idle-prompt hostage condition)}\
-constraints win_console_86_cooked\
-setup [string cat $common {
set prior_handler [chan event stdin readable]
chan event stdin readable {set ::probes_dummy_readable 1}
}]\
-body {
set status [catch {punk::console::get_cursor_pos} msg opts]
lappend result $status
lappend result [dict get $opts -errorcode]
}\
-cleanup {
chan event stdin readable $prior_handler
unset -nocomplain ::probes_dummy_readable
}\
-result [list\
1\
{PUNK CONSOLE QUERY HOSTAGE_COOKED_READ}\
]
test input_at_eof_stdin_windows_console {input_at_eof on a real windows console stdin returns 0 via the console guard - the pipe-branch probe read must not run (on tcl 8.6 a drained-console read parks a cooked ReadConsole that swallows subsequent query responses)}\
-constraints win_console_twapi\
-setup $common\
-body {
lappend result [punk::console::input_at_eof stdin]
#the console guard path must not have parked probe data
lappend result [expr {![info exists ::punk::console::input_chunks_waiting(stdin)]
|| ![llength $::punk::console::input_chunks_waiting(stdin)]}]
}\
-cleanup {}\
-result [list\
0\
1\
]
test is_input_console_or_tty_pipe_no_hints {open pipe with terminal env hints cleared is not considered a console/tty}\
-setup [string cat $common $hints_save]\
-body {

251
src/tests/modules/punk/mix/testsuites/repo/fossilmove.test

@ -0,0 +1,251 @@
# -*- tcl -*-
# Tests for the fossil repository move/rename area used by
# punk::mix::commandset::repo::fossil-move-repository:
# - punk::repo fossil discovery primitives against a scratch repo+checkout
# (fossil_get_configdb, find_fossil, fossil_get_repository_file)
# - the move primitive sequence the commandset uses (file copy + fossil
# test-move-repository) and what it leaves behind
# The 3.x/4.x tests are CHARACTERIZATION tests: they pin the current behaviour,
# including the gaps a future scriptable safe-move/rename must close (see root
# GOALS.md G-022): the old repo db file is left on disk, the central config-db
# keeps a phantom repo: entry for the old path, the old repo db keeps a stale
# ckout: back-reference, and nothing renames the fossil project-name.
# When that capability lands, the gap tests here are expected to be updated to
# assert the new (clean) behaviour.
#
# Isolation: all fossil activity runs with FOSSIL_HOME pointing into a temp
# folder, so the user's real central fossil config-db is never touched. Every
# mutating step is gated on verifying fossil actually reports its config-db
# under that temp folder.
#
# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/punk/mix/** fossilmove.test
package require tcltest
package require punk::lib
package require punk::repo
namespace eval ::testspace {
namespace import ::tcltest::*
# -------------------------------------------------------------------------
# Constraints and isolation guard
# -------------------------------------------------------------------------
set fossilcmd [auto_execok fossil]
tcltest::testConstraint fossil_available [expr {[llength $fossilcmd] > 0}]
variable env_saved_exists [info exists ::env(FOSSIL_HOME)]
variable env_saved_value [expr {$env_saved_exists ? $::env(FOSSIL_HOME) : ""}]
variable tempbase ""
variable fosshome ""
variable repodir ""
variable codir ""
proc path_eq {a b} {
string equal -nocase [file normalize $a] [file normalize $b]
}
proc run_fossil {workdir args} {
variable fossilcmd
set original_cwd [pwd]
try {
if {$workdir ne ""} {
cd $workdir
}
exec -ignorestderr {*}$fossilcmd {*}$args
} finally {
cd $original_cwd
}
}
# central config-db repo entries as reported by fossil itself
proc central_repo_list {} {
variable codir
set lines [run_fossil $codir all ls]
set paths [list]
foreach ln [split [string map {\r\n \n} $lines] \n] {
set ln [string trim $ln]
if {$ln ne ""} { lappend paths $ln }
}
return $paths
}
proc central_has_repo {repopath} {
foreach p [central_repo_list] {
if {[path_eq $p $repopath]} { return 1 }
}
return 0
}
# ckout: checkout-record paths registered in a repository db
proc repo_ckout_dirs {repodb} {
set lines [run_fossil "" sql -R $repodb \
"select name from config where name like 'ckout:%'"]
set dirs [list]
foreach ln [split [string map {\r\n \n} $lines] \n] {
set ln [string trim $ln]
if {[string match ckout:* $ln]} {
lappend dirs [string trimright [string range $ln 6 end] /]
}
}
return $dirs
}
proc repo_project_name {repodb} {
string trim [run_fossil "" sql -R $repodb \
"select value from config where name='project-name'"]
}
# -------------------------------------------------------------------------
# Fixture: isolated FOSSIL_HOME, scratch repo db + one open checkout.
# Any failure (including an isolation check failure) disables the
# fossil_fixture constraint so no test proceeds against the wrong config-db.
# -------------------------------------------------------------------------
variable fixture_error ""
if {[tcltest::testConstraint fossil_available]} {
try {
set tempbase [punk::lib::tempdir_newfolder -prefix pkfossilmovetest]
set fosshome [file join $tempbase fosshome]
set repodir [file join $tempbase repos]
set codir [file join $tempbase co]
file mkdir $fosshome $repodir $codir
set ::env(FOSSIL_HOME) $fosshome
run_fossil "" init [file join $repodir repoA.fossil] -project-name testmoveproj
run_fossil $codir open [file join $repodir repoA.fossil]
# isolation guard: fossil must report its config-db under our
# temp FOSSIL_HOME or nothing else may run
set infolines [run_fossil $codir info]
set configdb ""
foreach ln [split [string map {\r\n \n} $infolines] \n] {
if {[string match "config-db:*" [string trim $ln]]} {
set configdb [string trim [string range [string trim $ln] 10 end]]
}
}
if {$configdb eq "" || ![string match -nocase "[file normalize $fosshome]*" [file normalize $configdb]]} {
error "isolation check failed: fossil config-db '$configdb' is not under temp FOSSIL_HOME '$fosshome'"
}
} on error {result} {
set fixture_error $result
}
}
tcltest::testConstraint fossil_fixture [expr {
[tcltest::testConstraint fossil_available] && $fixture_error eq ""
}]
if {$fixture_error ne ""} {
puts stderr "fossilmove.test fixture setup failed (tests will be skipped): $fixture_error"
}
# -------------------------------------------------------------------------
# 1.x punk::repo discovery primitives against the scratch checkout
# -------------------------------------------------------------------------
test fossilmove-1.1 {fossil_get_configdb resolves to the FOSSIL_HOME-isolated config-db} -constraints fossil_fixture -body {
variable fosshome
set configdb [punk::repo::fossil_get_configdb $::testspace::codir]
string match -nocase "[file normalize $fosshome]*" [file normalize $configdb]
} -result 1
test fossilmove-1.2 {find_fossil locates the scratch checkout root} -constraints fossil_fixture -body {
variable codir
path_eq [punk::repo::find_fossil $codir] $codir
} -result 1
test fossilmove-1.3 {fossil_get_repository_file returns the opened repo db path} -constraints fossil_fixture -body {
variable codir
variable repodir
path_eq [punk::repo::fossil_get_repository_file $codir] [file join $repodir repoA.fossil]
} -result 1
# -------------------------------------------------------------------------
# 2.x central config-db registration baseline
# -------------------------------------------------------------------------
test fossilmove-2.1 {open registers the repo db in the central config-db} -constraints fossil_fixture -body {
variable repodir
central_has_repo [file join $repodir repoA.fossil]
} -result 1
test fossilmove-2.2 {repo db records its checkout as a ckout: entry} -constraints fossil_fixture -body {
variable repodir
variable codir
set dirs [repo_ckout_dirs [file join $repodir repoA.fossil]]
expr {[llength $dirs] == 1 && [path_eq [lindex $dirs 0] $codir]}
} -result 1
# -------------------------------------------------------------------------
# 3.x the move primitive sequence used by fossil-move-repository:
# file copy old->new, then fossil test-move-repository in the checkout.
# 3.1 pins the part that works; 3.2-3.4 pin the current gaps.
# -------------------------------------------------------------------------
variable move_error ""
if {[tcltest::testConstraint fossil_fixture]} {
try {
file copy [file join $repodir repoA.fossil] [file join $repodir repoB.fossil]
run_fossil $codir test-move-repository [file join $repodir repoB.fossil]
} on error {result} {
set move_error $result
}
}
tcltest::testConstraint fossil_moved [expr {
[tcltest::testConstraint fossil_fixture] && $move_error eq ""
}]
if {$move_error ne ""} {
puts stderr "fossilmove.test move sequence failed (3.x/4.x will be skipped): $move_error"
}
test fossilmove-3.1 {test-move-repository repoints the checkout to the new repo db} -constraints fossil_moved -body {
variable codir
variable repodir
path_eq [punk::repo::fossil_get_repository_file $codir] [file join $repodir repoB.fossil]
} -result 1
test fossilmove-3.2 {GAP: move-by-copy leaves the old repo db file on disk} -constraints fossil_moved -body {
# current commandset behaviour is file copy + repoint - the old db file
# remains; a safe scriptable move should remove/archive it once no
# checkout references it (G-022)
variable repodir
file exists [file join $repodir repoA.fossil]
} -result 1
test fossilmove-3.3 {GAP: central config-db keeps a phantom repo: entry for the old path} -constraints fossil_moved -body {
# both old and new paths are listed after the move - the old entry is
# the phantom a safe move must remove (G-022)
variable repodir
list [central_has_repo [file join $repodir repoA.fossil]] \
[central_has_repo [file join $repodir repoB.fossil]]
} -result {1 1}
test fossilmove-3.4 {GAP: old repo db retains a stale ckout: back-reference} -constraints fossil_moved -body {
# the checkout no longer uses repoA but repoA still claims it (G-022)
variable repodir
variable codir
set dirs [repo_ckout_dirs [file join $repodir repoA.fossil]]
expr {[llength $dirs] == 1 && [path_eq [lindex $dirs 0] $codir]}
} -result 1
# -------------------------------------------------------------------------
# 4.x project-name: moving/renaming the db file does not rename the project
# -------------------------------------------------------------------------
test fossilmove-4.1 {GAP: repo db file move/rename leaves fossil project-name unchanged} -constraints fossil_moved -body {
# no current commandset capability renames the project-name recorded in
# the repo db config table (G-022 requires it for shellspy->punkshell)
variable repodir
repo_project_name [file join $repodir repoB.fossil]
} -result testmoveproj
# -------------------------------------------------------------------------
# Cleanup: restore FOSSIL_HOME and remove the temp tree
# -------------------------------------------------------------------------
variable env_saved_exists
if {$env_saved_exists} {
set ::env(FOSSIL_HOME) $env_saved_value
} else {
unset -nocomplain ::env(FOSSIL_HOME)
}
if {$tempbase ne "" && [file isdirectory $tempbase]} {
catch {file delete -force $tempbase}
}
}
tcltest::cleanupTests
namespace delete ::testspace

198
src/tests/modules/punkboot/utils/testsuites/utils/utils.test

@ -0,0 +1,198 @@
# -*- tcl -*-
# Tests for punkboot::utils project-version helpers:
# - parse_punkproject_version: TOML content string -> version string
# - read_changelog_latest_version: changelog file -> latest ## [X.Y.Z] header
# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/punkboot/utils/** utils.test
package require tcltest
package require punk::lib
package require punkboot::utils
namespace eval ::testspace {
namespace import ::tcltest::*
# -------------------------------------------------------------------------
# Helper: write content to a temp file. Returns the file path.
# -------------------------------------------------------------------------
proc write_temp_file {prefix content} {
set dir [punk::lib::tempdir_newfolder -prefix $prefix]
set path [file join $dir data]
set fd [open $path w]
try { puts -nonewline $fd $content } finally { close $fd }
return $path
}
# =========================================================================
# parse_punkproject_version
# =========================================================================
test parse_ppv_standard {Standard [project] version line} -body {
set toml {[project]
name = "punkshell"
version = "0.2.0"
}
::punkboot::utils::parse_punkproject_version $toml
} -result "0.2.0"
test parse_ppv_no_version_field {Missing version key in [project] section} -body {
set toml {[project]
name = "punkshell"
}
::punkboot::utils::parse_punkproject_version $toml
} -result ""
test parse_ppv_version_in_wrong_section {Version in a non-[project] section is ignored} -body {
set toml {[project]
name = "punkshell"
[other]
version = "9.9.9"
}
::punkboot::utils::parse_punkproject_version $toml
} -result ""
test parse_ppv_empty_content {Empty string returns empty} -body {
::punkboot::utils::parse_punkproject_version ""
} -result ""
test parse_ppv_no_section {No section header at all} -body {
::punkboot::utils::parse_punkproject_version {version = "1.0.0"}
} -result ""
test parse_ppv_whitespace_around_equals {Whitespace variations around = are tolerated} -body {
set toml {[project]
version = "0.3.1"
}
::punkboot::utils::parse_punkproject_version $toml
} -result "0.3.1"
test parse_ppv_section_after_project {A later section does not override [project] version} -body {
set toml {[project]
version = "0.2.0"
[dependencies]
version = "1.5.0"
}
::punkboot::utils::parse_punkproject_version $toml
} -result "0.2.0"
test parse_ppv_singlequoted_version {Single-quoted value is not matched (TOML requires double quotes)} -body {
set toml {[project]
version = '0.2.0'
}
::punkboot::utils::parse_punkproject_version $toml
} -result ""
# =========================================================================
# read_punkproject_version
# =========================================================================
test read_ppv_file_not_found {Non-existent file returns empty} -body {
::punkboot::utils::read_punkproject_version /nonexistent/path/to/punkproject.toml
} -result ""
test read_ppv_file_standard {Read a real-ish TOML file} -body {
set path [write_temp_file ppvread {[project]
name = "punkshell"
version = "1.2.3"
}]
set result [::punkboot::utils::read_punkproject_version $path]
file delete -force [file dirname $path]
set result
} -result "1.2.3"
# =========================================================================
# read_changelog_latest_version
# =========================================================================
test read_clv_standard {Standard changelog with one entry} -body {
set content {# Changelog
## [0.2.0] - 2026-07-05
- Initial tracking.
}
set path [write_temp_file clvstd $content]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result "0.2.0"
test read_clv_multiple_entries {First (newest) header returned when multiple exist} -body {
set content {# Changelog
## [0.2.1] - 2026-07-06
- New subcommand.
## [0.2.0] - 2026-07-05
- Initial tracking.
}
set path [write_temp_file clvmulti $content]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result "0.2.1"
test read_clv_file_not_found {Non-existent file returns empty} -body {
::punkboot::utils::read_changelog_latest_version /nonexistent/path/to/CHANGELOG.md
} -result ""
test read_clv_empty_file {Empty file returns empty} -body {
set path [write_temp_file clvempty ""]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result ""
test read_clv_no_headers {File with no ## [X.Y.Z] headers returns empty} -body {
set content {# Changelog
Some prose without a version header.
}
set path [write_temp_file clvnohdr $content]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result ""
test read_clv_extra_spaces {Extra whitespace after ## is tolerated} -body {
set content {## [0.5.0] - 2026-08-01}
set path [write_temp_file clvspc $content]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result "0.5.0"
test read_clv_prerelease_suffix {Version with pre-release suffix is captured} -body {
set content {## [0.2.1-rc1] - 2026-07-06}
set path [write_temp_file clvpre $content]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result "0.2.1-rc1"
test read_clv_single_hash {Single # header is not matched (only ##)} -body {
set content {# [0.1.0] this is a single hash}
set path [write_temp_file clv1h $content]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result ""
test read_clv_triple_hash {Triple ### subheader is not matched} -body {
set content {## [0.2.0] - 2026-07-05
### Fixed
- something
}
set path [write_temp_file clv3h $content]
set result [::punkboot::utils::read_changelog_latest_version $path]
file delete -force [file dirname $path]
set result
} -result "0.2.0"
tcltest::cleanupTests
}

98
src/tests/modules/punkboot/utils/testsuites/utils/vcsdirty.test

@ -0,0 +1,98 @@
# -*- tcl -*-
# Tests for punkboot::utils::vcs_dirty_warnings:
# - walk-up VCS root discovery, dirty/clean reporting, per-root dedupe via the
# caller's checkedroots dict, label prefixing, silence for unversioned or
# missing paths.
# Uses a scratch git repo fixture (git side); the fossil branch is symmetric
# code exercised end-to-end by make.tcl vendorupdate and the FOSSIL_HOME
# isolation pattern in modules/punk/mix/testsuites/repo/fossilmove.test.
# Run: tclsh src/tests/runtests.tcl -report compact -show-passes 0 -include-paths modules/punkboot/utils/** vcsdirty.test
package require tcltest
package require punk::lib
package require punkboot::utils
namespace eval ::testspace {
namespace import ::tcltest::*
set gitcmd [auto_execok git]
tcltest::testConstraint git_available [expr {[llength $gitcmd] > 0}]
variable fixture_error ""
variable gitroot ""
variable subdir ""
if {[tcltest::testConstraint git_available]} {
try {
set gitroot [punk::lib::tempdir_newfolder -prefix pkvcsdirtytest]
exec {*}$gitcmd -C $gitroot init -q
set subdir [file join $gitroot deep nested]
file mkdir $subdir
set fd [open [file join $subdir f.txt] w]
try { puts $fd "uncommitted content" } finally { close $fd }
} on error {result} {
set fixture_error $result
}
}
tcltest::testConstraint git_fixture [expr {
[tcltest::testConstraint git_available] && $fixture_error eq ""
}]
if {$fixture_error ne ""} {
puts stderr "vcsdirty.test fixture setup failed (tests will be skipped): $fixture_error"
}
test vcsdirty-1.1 {dirty git repo produces one warning naming the root} -constraints git_fixture -body {
variable subdir
variable gitroot
set checked [dict create]
set warnings [::punkboot::utils::vcs_dirty_warnings $subdir checked]
list [llength $warnings] \
[string match "WARNING: source project at [file normalize $gitroot] has uncommitted git changes*" [lindex $warnings 0]]
} -result {1 1}
test vcsdirty-1.2 {label is prefixed into the warning} -constraints git_fixture -body {
variable subdir
set checked [dict create]
set warnings [::punkboot::utils::vcs_dirty_warnings $subdir checked someop]
string match "WARNING: someop source project at *" [lindex $warnings 0]
} -result 1
test vcsdirty-2.1 {checkedroots dict dedupes repeat reports of the same root} -constraints git_fixture -body {
variable subdir
variable gitroot
set checked [dict create]
set first [::punkboot::utils::vcs_dirty_warnings $subdir checked]
set second [::punkboot::utils::vcs_dirty_warnings $gitroot checked]
list [llength $first] [llength $second]
} -result {1 0}
test vcsdirty-3.1 {unversioned path is silent} -constraints git_available -body {
set checked [dict create]
set dir [punk::lib::tempdir_newfolder -prefix pkvcsdirtynone]
set result [::punkboot::utils::vcs_dirty_warnings $dir checked]
file delete -force $dir
set result
} -result {}
test vcsdirty-3.2 {missing path is silent} -constraints git_available -body {
set checked [dict create]
::punkboot::utils::vcs_dirty_warnings [file join [punk::lib::tempdir] no_such_dir_xyz_12345] checked
} -result {}
test vcsdirty-4.1 {clean git repo is silent} -constraints git_fixture -body {
variable gitroot
set gitcmd [auto_execok git]
exec {*}$gitcmd -C $gitroot add -A
exec {*}$gitcmd -C $gitroot -c user.email=t@t -c user.name=t commit -q -m fixture
set checked [dict create]
::punkboot::utils::vcs_dirty_warnings $gitroot checked
} -result {}
# cleanup fixture
variable gitroot
if {$gitroot ne "" && [file isdirectory $gitroot]} {
catch {file delete -force $gitroot}
}
}
tcltest::cleanupTests
namespace delete ::testspace

22
src/tests/runtests.tcl

@ -271,23 +271,43 @@ proc runtests_print_slowest_tests {slowest_tests show_timings} {
#------------------------------------
#for the tests running in child processes,
#use the unbuilt modules/libraries under development in preference to the installed versions.
#ORDERING NOTE (rules verified experimentally on Tcl 9.0.3, 2026-07-06 - identical results
#under Tcl's standard 'package unknown' scanner and punk::libunknown):
# - tcl::tm::add PREPENDS each of its arguments in turn, so a single call
# 'tcl::tm::add {*}$test_tmlist' produces tcl::tm::list in REVERSED test_tmlist order
# (the last element of test_tmlist ends up at the head of tcl::tm::list).
# - For the SAME module name-version found under multiple tm paths, the copy under the
# path nearest the HEAD of tcl::tm::list wins (first-scanned registration is kept).
# - Net effect: the LAST element of test_tmlist wins same-version ties.
#Different-version selection is unaffected by order: dev modules (999999.0a1.0) win via
#'package prefer latest' wherever they sit. But bootsupport and vendormodules snapshots can
#carry the SAME version of a module with different code (a version-bump-discipline failure in
#the source project, but it happens - observed 2026-07-06 with a stale vendormodules
#textblock 0.1.3 shadowing the fresh bootsupport copy and breaking shellfilter::run).
#bootsupport is deliberately the LAST test_tmlist element so the punkshell-managed bootsupport
#snapshot wins any same-version tie.
set test_tmlist [list]
set ifneeded_script ""
lappend test_tmlist [file normalize $test_base/../modules] ;#ie <projectroot>/src/modules
append ifneeded_script [punk::tcltestrun::tm_path_additional_ifneeded [file normalize $test_base/../modules] $project_root]
lappend test_tmlist [file normalize $test_base/../modules_tcl$tcl_major]
append ifneeded_script [punk::tcltestrun::tm_path_additional_ifneeded [file normalize $test_base/../modules_tcl$tcl_major] $project_root]
lappend test_tmlist [file normalize $test_base/../bootsupport/modules]
#vendored module dependencies (e.g voo) must be resolvable by modules/tests using them
lappend test_tmlist [file normalize $test_base/../vendormodules]
if {[file isdirectory $test_base/../vendormodules_tcl$tcl_major]} {
lappend test_tmlist [file normalize $test_base/../vendormodules_tcl$tcl_major]
}
lappend test_tmlist [file normalize $test_base/../bootsupport/modules] ;#after vendormodules - wins same-version ties
set test_auto_path [list]
lappend test_auto_path [file normalize $test_base/../lib]
lappend test_auto_path [file normalize $test_base/../lib/tcl$tcl_major]
lappend test_auto_path [file normalize $test_base/../../lib_tcl$tcl_major/$arch]
lappend test_auto_path [info library]
#the parent of [info library] is where an installation's binary packages usually live
#(e.g Thread, needed by shellrun in the testinterp) - the default tclsh auto_path has it,
#but we replace auto_path wholesale in the testinterp, and not every project carries a
#root lib_tcl<N>/<arch> runtime payload that would otherwise supply Thread.
lappend test_auto_path [file dirname [info library]]
#------------------------------------

134
src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/console-0.6.0.tm → src/vfs/_vfscommon.vfs/modules/punk/console-0.7.1.tm

@ -7,7 +7,7 @@
# (C) 2023
#
# @@ Meta Begin
# Application punk::console 0.6.0
# Application punk::console 0.7.1
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
@ -17,7 +17,7 @@
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::console 0 0.6.0]
#[manpage_begin punkshell_module_punk::console 0 0.7.1]
#[copyright "2024"]
#[titledesc {punk console}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk console}] [comment {-- Description at end of page heading --}]
@ -564,6 +564,27 @@ namespace eval punk::console {
set cinfo [punk::console::console_spec_resolve $inoutchannels]
set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]]
lassign $inoutchannels input output
#G-007 choke-point brokering: a query on a console owned by another thread runs in
#the owning thread - where the cooperative reader protocol (input_chunks_waiting),
#raw-mode arbitration and the authoritative settled can_respond live. Forwarding the
#whole call means the queueing, raw cycling and reader cooperation below all execute
#in the owner's context, and every query proc layered above this choke point
#inherits the routing. Routed before the local can_respond gate deliberately: a
#non-owner context's anchored view of the default console may be unsettled while
#the owner's is settled. The synchronous thread::send is safe because the owner
#services events while this thread blocks (the property the repl-installed vt52
#alias transport already relies on), and re-entry in the owner resolves to
#owner==self so the forward cannot ping-pong.
set route_owner [console_route_owner $inoutchannels]
if {$route_owner ne ""} {
try {
return [thread::send $route_owner [list ::punk::console::internal::get_ansi_response_payload -console $inoutchannels -expected_ms $expected -ignoreok $ignoreok -return $returntype -passthrough $passthrough $query $capturingendregex]]
} on error {errM erropts} {
return -code error "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM"
}
}
set governing_obj [dict get $cinfo object]
if {$governing_obj ne ""} {
set governing_cr [::opunk::Console::get.o_can_respond $governing_obj]
@ -684,6 +705,16 @@ namespace eval punk::console {
#For mintty-without-winpty (terminal presenting pipes) the twapi raw cycle was already a
#no-op via its get_console_handle early-return, so nothing is lost by skipping.
set input_is_console_or_tty [expr {[dict exists $previous_input_state -inputmode] || [dict exists $previous_input_state -mode]}]
if {!$input_is_console_or_tty && $input eq "stdin" && $::punk::console::has_twapi} {
#Tcl 8.6 windows console channels have no -inputmode configure key, so the
#dict test alone misclassifies a real 8.6 console as a pipe: the raw cycle
#is then skipped and the query response sits in the cooked line buffer until
#Enter - timeout here plus the response leaking to the line reader as phantom
#input. A twapi console handle for stdin is definitive. Channels other than
#stdin stay excluded - the guard's purpose (pipe probes must not flip the
#process console) is unchanged.
set input_is_console_or_tty [expr {![catch {twapi::get_console_handle stdin}]}]
}
if {![tsv::get punk_console is_raw]} {
set was_raw 0
if {$input_is_console_or_tty} {
@ -1988,6 +2019,16 @@ namespace eval punk::console {
#pending keystroke with a probe read here.
return 0
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key, so the dict test above
#misses them. Beyond the pending-keystroke concern, the probe read is fatal here:
#a read on a drained 8.6 console channel makes the channel driver park a blocking
#cooked-mode ReadConsole that cannot be cancelled by a later raw-mode flip - it
#swallows a terminal query response emitted afterwards (get_ansi_response_payload
#calls this guard immediately before its raw cycle) and only completes on Enter.
return 0
}
#pipe-like channel - probe without blocking to force eof detection
if {[catch {
set prior_blocking [dict get $conf -blocking]
@ -2036,6 +2077,14 @@ namespace eval punk::console {
#tty on unix-like platforms
return 1
}
variable has_twapi
if {$input eq "stdin" && $has_twapi && ![catch {twapi::get_console_handle stdin}]} {
#Tcl 8.6 windows console channels expose no -inputmode key - a twapi console
#handle for stdin is definitive: process stdin is the real console. Terminals
#presenting channels as pipes (mintty without winpty) have no console handle
#and fall through to the env heuristics below.
return 1
}
if {[input_at_eof $input]} {
return 0
}
@ -2405,6 +2454,37 @@ namespace eval punk::console {
set owner [punk::console::console_owner_get {stdin stdout}]
return [expr {$owner eq "" || $owner eq [thread::id]}]
}
punk::args::define {
@id -id ::punk::console::internal::console_route_owner
@cmd -name punk::console::internal::console_route_owner -summary\
"Owning thread a console operation must be brokered to, or empty string to operate locally."\
-help\
"Routing consults the ownership registry for the process-default console
{stdin stdout} only: std channel names are process-wide, so any context can
refer to the shared console, but exactly one context owns its reader and
raw-mode arbitration. Non-std channel names are thread-local - an {in out}
pair spec names channels of the calling thread, making that thread the
console's local context - so non-default pairs always operate locally
(a registry entry for such a pair qualifies the shared fact store; it is
not a routing target). A console anchored by code-interp/worker code is
therefore operated on directly by its anchoring context, with no round-trip.
Returns empty string when the caller is the owner or no live owner is
registered."
@values -min 1 -max 1
inoutchannels -type list -help\
"Canonical {in out} channel pair."
}
proc console_route_owner {inoutchannels} {
#see PUNKARGS id ::punk::console::internal::console_route_owner
if {[lindex $inoutchannels 0] ne "stdin" || [lindex $inoutchannels 1] ne "stdout"} {
return ""
}
set owner [punk::console::console_owner_get {stdin stdout}]
if {$owner eq "" || $owner eq [thread::id]} {
return ""
}
return $owner
}
punk::args::define {
@id -id ::punk::console::internal::console_share_qualifier
@cmd -name punk::console::internal::console_share_qualifier -summary\
@ -2809,7 +2889,14 @@ namespace eval punk::console {
proc ensure_object_integration {} {
variable object_integration_done
if {$object_integration_done} {
return
return 1
}
if {![namespace exists ::opunk::console]} {
#opunk::console not loaded - nothing to integrate with yet. In-module callers only
#reach here after a successful 'package require opunk::console'; external/manual
#callers may call speculatively. Deliberately does not set object_integration_done,
#so a later call after opunk::console loads performs the wiring.
return 0
}
set ::opunk::console::waiting_chunks_arrayvar ::punk::console::input_chunks_waiting
if {$::opunk::console::size_query_provider eq ""} {
@ -2820,8 +2907,27 @@ namespace eval punk::console {
#consult-time liveness validation still cover the default console there)
if {[info exists ::opunk::console::lifecycle_callback] && $::opunk::console::lifecycle_callback eq ""} {
set ::opunk::console::lifecycle_callback [list ::punk::console::internal::object_console_lifecycle]
#catch-up registration: anchors created in this interp before the callback was
#wired never fired a 'created' event, so their consoles have no recorded owner
#(seen when user code does 'package require opunk::console; opunk::console::create ...'
#before any punk::console object operation triggers this wiring). Anchors are
#per-interp/per-thread, so the anchoring context is this thread - register it.
#Only fills empty entries: an existing live registration is newer information than
#these pre-wiring anchors (and for the default console first-registration wins).
foreach v [info vars ::opunk::console::instances::*] {
if {![info exists $v]} {
continue
}
if {[catch {::opunk::Console::channels [set $v]} channels]} {
continue
}
if {[console_owner_get $channels] eq ""} {
console_owner_register $channels
}
}
}
set object_integration_done 1
return 1
}
lappend PUNKARGS [list {
@ -2892,12 +2998,19 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::move 2000 2000]
#flush before querying: the position query may execute in the console-owning
#thread (G-007 routing), whose flush acts on its own channel instance for the same
#OS handle - an unflushed move here would leave the terminal reporting the
#unmoved cursor position (seen as 'columns 1' on tcl 8.6 where the -winsize
#shortcut is unavailable and this mechanism actually runs)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::move $start_row $start_col][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::move $start_row $start_col]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -2931,12 +3044,16 @@ namespace eval punk::console {
#some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that.
#This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere.
puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::cursor_save_dec][punk::ansi::move 2000 2000]
#flush before querying - see get_size_using_cursormove (G-007 routing: the query
#may flush a different channel instance in the console-owning thread)
flush $out
lassign [get_cursor_pos_list $inoutchannels] lines cols
puts -nonewline $out [punk::ansi::cursor_restore][punk::ansi::cursor_on];flush $out
set result [dict create columns $cols rows $lines]
} errM]} {
puts -nonewline $out [punk::ansi::cursor_restore_dec]
puts -nonewline $out [punk::ansi::cursor_on]
catch {flush $out}
error "$errM"
} else {
return $result
@ -4186,6 +4303,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before each cursor query: the query may execute in the console-owning thread
#(G-007 routing) whose flush acts on its own channel instance - unflushed emissions
#here would be measured as if they never happened (see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -4207,6 +4328,7 @@ namespace eval punk::console {
} errM]} {
puts stderr "test_char_width couldn't emit this string - \nerror: $errM"
}
flush $out ;#the test emission must reach the terminal before the (possibly routed) query measures it
set response [punk::console::get_cursor_pos $inoutchannels]
lassign [split $response ";"] _row2 col2
@ -4261,6 +4383,10 @@ namespace eval punk::console {
if {!$emit} {
puts -nonewline $out \033\[2K\033\[1G ;#2K erase line, 1G cursor at col1
}
#flush before the cursor query - the alt-screen/move/erase emissions above must reach
#the terminal first (the query may flush a different channel instance in the
#console-owning thread under G-007 routing - see get_size_using_cursormove)
flush $out
set response ""
if {[catch {
set response [punk::console::get_cursor_pos $inoutchannels]
@ -6202,7 +6328,7 @@ namespace eval ::punk::args::register {
## Ready
package provide punk::console [namespace eval punk::console {
variable version
set version 0.6.0
set version 0.7.1
}]
return

51
src/vfs/_vfscommon.vfs/modules/punk/repl-0.2.1.tm → src/vfs/_vfscommon.vfs/modules/punk/repl-0.2.2.tm

@ -1635,6 +1635,7 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
#note -inputmode not available in Tcl 8.6 for chan configure!
#According to DKF - -buffering option doesn't affect input channels
set rawmode 0
set waiting_needs_reader 0 ;#set when the 8.6-console waiting-chunks path skips its read on a drained channel - the readable handler must be armed (not the after-idle reinvoke) or the pending partial line could never complete
set original_input_conf [chan configure $inputchan] ;#whether repl is in line or raw mode - we restore the inputchan (stdin) state
if {[dict exists $original_input_conf -inputmode]} {
@ -1693,9 +1694,46 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set chunk [read $inputchan]
set hostage_noread 0
if {"windows" eq $::tcl_platform(platform)
&& ![dict exists $original_input_conf -inputmode]
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]} {
#tcl 8.6 windows console channel (no -inputmode configure key, real console
#handle). On the 8.6 console driver a read on a *drained* channel parks a
#blocking cooked-mode ReadConsole that a later raw-mode flip cannot cancel:
#it swallows terminal query responses until Enter (see punk::console 0.7.1
#buildversion notes). This waiting-chunks path is normally entered via
#'after idle' with the channel drained - only consume what is already in the
#Tcl channel buffer (sized read - no driver probe); when nothing is buffered,
#process the stashed complete lines without reading and arm the readable
#handler for the rest.
set avail [chan pending input $inputchan]
if {$avail > 0} {
set chunk [read $inputchan $avail]
} else {
set chunk ""
set hostage_noread 1
set waiting_needs_reader 1
}
} else {
set chunk [read $inputchan]
}
set chunksize [string length $chunk]
if {$chunksize > 0} {
if {$hostage_noread} {
#nothing in the Tcl buffer and we must not probe the drained driver.
#Process the complete stashed lines now; a remaining partial line can only
#progress via more user input, which the armed readable handler delivers.
if {[llength $stdinlines]} {
punk::repl::repl_handler_restorechannel_if_not_eof $inputchan $original_input_conf
uplevel #0 [list repl::repl_process_data $inputchan line "" $stdinlines $prompt_config]
}
if {$waitingchunk ne ""} {
set input_chunks_waiting($inputchan) [list $waitingchunk]
} else {
set input_chunks_waiting($inputchan) [list]
}
} elseif {$chunksize > 0} {
if {[string index $chunk end] eq "\n"} {
lappend stdinlines $waitingchunk[string range $chunk 0 end-1]
#punk::console::cursorsave_move_emitblock_return 30 30 "repl_handler num_stdinlines [llength $stdinlines] chunk:$yellow[ansistring VIEW -lf 1 $chunk][a] fblocked:[fblocked $inputchan] pending:[chan pending input stdin]"
@ -1864,7 +1902,12 @@ proc repl::repl_handler {inputchan readmore prompt_config} {
##################################################################################
#Re-enable channel read handler only if no waiting chunks - must process in order
##################################################################################
if {![llength $input_chunks_waiting($inputchan)]} {
if {![llength $input_chunks_waiting($inputchan)] || $waiting_needs_reader} {
#waiting_needs_reader: the 8.6-console waiting-chunks path found nothing in the
#Tcl channel buffer and must not probe the drained driver - the waiting data is
#an incomplete line that can only progress when the user types more, so arm the
#reader (the driver listening for input is its job here) instead of an after-idle
#reinvoke that could never make progress.
chan event $inputchan readable [list ::repl::repl_handler $inputchan $readmore $prompt_config]
} else {
#review
@ -4172,7 +4215,7 @@ namespace eval repl {
}
package provide punk::repl [namespace eval punk::repl {
variable version
set version 0.2.1
set version 0.2.2
}]
#repl::start $program_read_stdin_pipe

13
src/vfs/critcl-3.3.1.vfs/TODO.txt

@ -0,0 +1,13 @@
(7) Document the @placeholders@ available to the various C code fragments.
(8) Note that we can use #define's to make the class and instance
types available as fixed named without resorting to placeholders,
and still keeping it in a local scope.
---
idea: critcl::cproc -- api to register return types, argument types
argument types: conversion from Tcl_Obj*, type checking (values)
return types: conversion to Tcl_Obj*, (type/value checking ?)

13
src/vfs/critclxxx.vfs/TODO.txt

@ -0,0 +1,13 @@
(7) Document the @placeholders@ available to the various C code fragments.
(8) Note that we can use #define's to make the class and instance
types available as fixed named without resorting to placeholders,
and still keeping it in a local scope.
---
idea: critcl::cproc -- api to register return types, argument types
argument types: conversion from Tcl_Obj*, type checking (values)
return types: conversion to Tcl_Obj*, (type/value checking ?)
Loading…
Cancel
Save