Compare commits

...

37 Commits

Author SHA1 Message Date
Julian Noble 2157c01f6c tclcore 0.3.3: remove parse-inert @dynamic tags from split, array, join (project 0.12.20) 3 weeks ago
Julian Noble b35ea12896 punk::args 0.11.2: bad-@dynamic warn-once + round-1 caching; tclcore 0.3.2: fix malformed tcl_startOfNextWord (project 0.12.19) 3 weeks ago
Julian Noble 2e603413e6 tclcore 0.3.1: after id-shape harvest via tstr placeholders in the argdoc defspace (project 0.12.18) 3 weeks ago
Julian Noble 367b65759c goals: add G-075 (punk::args (package) ids - lookup fix + package documentation surface) 3 weeks ago
Julian Noble f1c0ea10a0 punk::args 0.11.1: define doc - registration styles and tstr/defspace interpolation rules (project 0.12.17) 3 weeks ago
Julian Noble a3d3088b03 tclcore/G-055: correct the parse-field tstr finding - expansion happens in the argdoc defspace, not never 3 weeks ago
Julian Noble 5a553b4ff3 tclcore 0.3.0: after cancel-id discrimination via harvested id shape (project 0.12.16) 3 weeks ago
Julian Noble 4a836f7faa G-055 detail: after cancel-id discrimination probe - stringstartswith(after#) viable, id shape stable 8.6/9.0, liveness residual (user suggestion 2026-07-13) 3 weeks ago
Julian Noble 7952b5caa4 goals: add G-074 (punk::args multiform ambiguity lint-time analysis) 3 weeks ago
Julian Noble a528b8dac4 G-055 detail: lseq operand typing probe - indexexpression fit vs expr syntax validation (user query 2026-07-13) 3 weeks ago
Julian Noble 2c919d9e4b goals: archive G-041 detail file (pure rename) 3 weeks ago
Julian Noble 7e7d515cd0 G-041 achieved: multi-form matching - automated form selection for parsing and documentation 3 weeks ago
Julian Noble 4707af6fc2 G-041 increment 2: doc surface presents the matching form (punk::ns 0.5.0, punk::args 0.11.0, project 0.12.15) 3 weeks ago
Julian Noble d85608d685 G-041 increment 1: multi-form candidacy engine (punk::args 0.10.0, project 0.12.14) 3 weeks ago
Julian Noble fa20bc689b G-041 proposed -> active (user-confirmed 2026-07-13) 3 weeks ago
Julian Noble 07481e9a47 goals: add G-073 (punk::args unavailable choices) 3 weeks ago
Julian Noble ede77b59d3 goals: archive G-051 detail file (pure rename) 3 weeks ago
Julian Noble deb8f17d72 G-051 achieved: doc-walk choice-prefix parity + truthful doconly cmdtype (punk::ns 0.4.0, punk::lib 0.4.1, project 0.12.13) 3 weeks ago
Julian Noble 72d50f91df punk::ns 0.3.0: restore bare-query failure banner in cmdhelp (project 0.12.12) 3 weeks ago
Julian Noble d26decb57d goals: archive G-071 detail file (pure rename) 3 weeks ago
Julian Noble d4519f41d3 G-071 achieved: punk::args 0.9.0 allocation choice screen - lseq-class optional-element arglists parse in-form (project 0.12.11) 3 weeks ago
Julian Noble 17b8a6dbbd G-071 increment 1: allocation characterization suite + retreat-path debug silencing (punk::args 0.8.3, project 0.12.10) 3 weeks ago
Julian Noble 803b44bd6c goals: add G-071 (optional-element allocation correctness) and G-072 (compound clause types) 3 weeks ago
Julian Noble 58da288d45 G-041 prework: if/switch/try/lseq real-vs-model probe findings recorded; punk::args 0.8.2 debug-leak fix (project 0.12.9) 3 weeks ago
Julian Noble b3b9992457 goals: add G-069 (splitter tclparser cross-check lint) and G-070 (pure-Tcl tclparser) 3 weeks ago
Julian Noble a20ca2cc08 goals: archive G-045 detail file (pure rename) 3 weeks ago
Julian Noble 66df76c82e G-045 achieved: punk::args 0.8.1 define quoting documentation; flip + archival edits (project 0.12.8) 3 weeks ago
Julian Noble 22606faebe G-045 increment 4: punk::args 0.8.0 @normalize directive; define_docs converts as consumer proof (project 0.12.7) 3 weeks ago
Julian Noble 52ad1211a2 G-045 increment 3: punk::args 0.7.0 - record-continuation token -& (project 0.12.6) 3 weeks ago
Julian Noble 590b09d38c goals: G-045 record define_docs end-state decision (indented-plus-normalized) 3 weeks ago
Julian Noble 12f9f16097 G-045 increment 2: punk 0.2.4 - i help renders aligned via -unindentedfields (project 0.12.5) 3 weeks ago
Julian Noble bb9ff66f51 G-045 increment 1: punk::args 0.6.1 - @cmd honours -unindentedfields for -help (project 0.12.4) 3 weeks ago
Julian Noble bf0f16bac5 project 0.12.3: changelog coverage for punk::args 0.6.0 synopsis change now shipped in vfs 3 weeks ago
Julian Noble 32471cb9ea AGENTS.md: record plain-ASCII default for agent-authored text 3 weeks ago
Julian Noble 15c1063796 vfs: sync punk::args 0.6.0 payload into _vfscommon.vfs (module work committed in c309ed47) 3 weeks ago
Julian Noble bac122da9f G-039 achieved: punk::repl 0.5.0 dead-console watchdog stops orphaned-shell CPU spin (project 0.12.2) 3 weeks ago
Julian Noble fde2f77be3 goals: vendoring goal cluster G-065..G-068; G-063 license verification provenance 3 weeks ago
  1. 1
      AGENTS.md
  2. 76
      CHANGELOG.md
  3. 19
      GOALS-archive.md
  4. 56
      GOALS.md
  5. 10
      goals/G-038-piped-session-continuity.md
  6. 49
      goals/G-039-orphan-console-spin.md
  7. 87
      goals/G-041-punkargs-form-matching.md
  8. 10
      goals/G-044-repl-command-completion.md
  9. 97
      goals/G-045-punkargs-authoring-ergonomics.md
  10. 5
      goals/G-052-oo-method-autodef.md
  11. 5
      goals/G-053-punkargs-multiple-ranges.md
  12. 91
      goals/G-055-tclcore-regen-workflow.md
  13. 8
      goals/G-063-package-license-tracking.md
  14. 67
      goals/G-065-declarative-vendoring.md
  15. 40
      goals/G-066-pkgindex-tm-repackaging.md
  16. 37
      goals/G-067-module-artifact-channel.md
  17. 41
      goals/G-068-vendored-moduledoc-workflow.md
  18. 50
      goals/G-069-splitter-tclparser-lint.md
  19. 56
      goals/G-070-pure-tcl-tclparser.md
  20. 68
      goals/G-072-punkargs-compound-clause-types.md
  21. 64
      goals/G-073-punkargs-unavailable-choices.md
  22. 86
      goals/G-074-punkargs-multiform-ambiguity-lint.md
  23. 67
      goals/G-075-punkargs-package-ids.md
  24. 157
      goals/archive/G-039-orphan-console-spin.md
  25. 221
      goals/archive/G-041-punkargs-form-matching.md
  26. 263
      goals/archive/G-045-punkargs-authoring-ergonomics.md
  27. 39
      goals/archive/G-051-cmdinfo-pseudo-and-prefix.md
  28. 130
      goals/archive/G-071-punkargs-optional-allocation.md
  29. 2
      punkproject.toml
  30. 18
      src/modules/punk-999999.0a1.0.tm
  31. 4
      src/modules/punk-buildversion.txt
  32. 1
      src/modules/punk/AGENTS.md
  33. 887
      src/modules/punk/args-999999.0a1.0.tm
  34. 13
      src/modules/punk/args-buildversion.txt
  35. 50
      src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm
  36. 6
      src/modules/punk/args/moduledoc/tclcore-buildversion.txt
  37. 12
      src/modules/punk/lib-999999.0a1.0.tm
  38. 3
      src/modules/punk/lib-buildversion.txt
  39. 158
      src/modules/punk/ns-999999.0a1.0.tm
  40. 5
      src/modules/punk/ns-buildversion.txt
  41. 83
      src/modules/punk/repl-999999.0a1.0.tm
  42. 3
      src/modules/punk/repl-buildversion.txt
  43. 162
      src/tests/modules/punk/args/testsuites/args/allocation.test
  44. 96
      src/tests/modules/punk/args/testsuites/args/dynamic.test
  45. 176
      src/tests/modules/punk/args/testsuites/args/forms.test
  46. 192
      src/tests/modules/punk/args/testsuites/args/normalize.test
  47. 182
      src/tests/modules/punk/args/testsuites/args/recordcontinuation.test
  48. 29
      src/tests/modules/punk/args/testsuites/args/rendering.test
  49. 63
      src/tests/modules/punk/args/testsuites/args/tclcoreparity.test
  50. 153
      src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test
  51. 72
      src/vfs/_vfscommon.vfs/modules/punk/args-0.6.0.tm
  52. 85
      src/vfs/_vfscommon.vfs/modules/punk/repl-0.5.0.tm

1
AGENTS.md

@ -94,6 +94,7 @@ When the user requests a durable behavior change, record it here or in the relev
- LF line endings are strongly preferred for all files in this repository. Converting a CRLF text file to LF when an edit touches it is correct and welcome - do not preserve CRLF for diff-minimisation. Preserve existing line endings only for files with deliberately mixed/CRLF endings (e.g. line-ending round-trip test data) or when explicitly instructed for a file.
- If the active editor is on a source-derived snapshot, bootstrap copy, or build output path such as `src/bootsupport/`, root `modules/`, root `lib/`, `modules_tcl8/`, `modules_tcl9/`, `lib_tcl8/`, or `lib_tcl9/`, confirm the intended target before editing unless the user explicitly named that path.
- cmd.exe PATH truncation (this machine, and any Windows machine with a heavily populated PATH): cmd.exe truncates a long PATH, so a tool that resolves fine in PowerShell may be "not found" when invoked via `cmd.exe /c`. Use absolute executable paths inside any `cmd /c` command line, and prefer PowerShell-native invocation unless a console host is specifically required (e.g. hidden-console test harnesses). If a tool is missing only under cmd.exe, suspect truncation before absence.
- Agent-authored text is plain ASCII by default: no em/en dashes, curly quotes, arrow or ellipsis characters, or other typographic Unicode - use ASCII equivalents (" - ", straight quotes, "->", "..."). This applies with extra force to outward-bound artifacts (ticket drafts, bug reports, emails, commit messages, anything likely to be pasted into an external system): those must be pure ASCII, verified before handover (e.g. grep for `[^\x00-\x7F]`). Legitimate exceptions: content whose subject matter is itself non-ASCII (encoding/Unicode/ANSI-art test data, or documentation demonstrating such behaviour), verbatim quotes of existing material, and cases where the user explicitly requests non-ASCII. Existing files are not to be bulk-retrofitted - the rule governs newly written text.
- Throwaway fossil repositories (test/experiment repos an agent creates, e.g. in a session scratchpad) must not register in the user's real global fossil config-db: `fossil init`/`fossil open` write persistent `repo:`/`ckout:` rows into `%LOCALAPPDATA%\_fossil`, which is the enumeration source for `dev projects.work` project discovery (G-016/G-017). Set `FOSSIL_HOME` to a disposable scratch directory for the duration of such fossil commands (both fossil and `punk::repo::fossil_get_configdb` honour it first). If pollution has already occurred: `fossil all ignore <repo-path>`, delete the directory, then any `fossil all` command prunes the orphaned `ckout:` row.
- Do not commit new executable binaries (shared libs, .exe, native .so/.dll/.dylib, bare ELF/Mach-O, or zip-based .tm modules embedding executables) to the repository. Existing binaries in `bin/`, `src/vfs/`, `src/vendorlib/`, `src/vendormodules/`, and `src/bootsupport/` are there intentionally pending the build/retrieval infrastructure tracked by goals G-004/G-005/G-006; do not flag, "fix", or hassle the developer about these — they are known and will be removed once G-005 (zig build) or G-006 (pre-built download) provides an alternative. This rule stops agents from adding new binaries; it does not block the developer's interim commits of existing vendor/vfs binaries. It is workflow policy only - deliberately NOT enforced at the local VCS layer: fossil `binary-glob` is `*` (versioned in `.fossil-settings/`) so binary checkins proceed without warnings/prompts, here and in sub-projects like tomlish.

76
CHANGELOG.md

@ -5,6 +5,82 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to
Entries are newest-first; one bullet per notable change. See the root `AGENTS.md`
"Project Versioning" section for the bump policy.
## [0.12.20] - 2026-07-13
- tclcore moduledoc 0.3.3: removed the unnecessary @dynamic tags from the split, array and join definitions - no bad-@dynamic warnings remain anywhere in the module, and the three definitions now cache their display expansion like the rest instead of re-expanding per render.
## [0.12.19] - 2026-07-13
- punk::args 0.11.2: the "bad @dynamic tag" warning now emits once per definition instead of on every resolve ('i join' emitted it 4x, user-reported) - the underlying inefficiency fixed: parse-inert @dynamic definitions now cache their round-1 resolution like legitimately dynamic ones instead of redoing the full text substitution on every resolve. The warning wording now notes @dynamic still forces display-field re-expansion per render for such definitions. tclcore moduledoc 0.3.2: fixed the malformed ::tcl_startOfNextWord definition (unescaped quotes in its embedded man-page example prevented it resolving at all - 'i tcl_startOfNextWord' now works), found by sweeping all 462 registered ids; the sweep identified ::split, ::array and ::join as the parse-inert @dynamic carriers.
## [0.12.18] - 2026-07-13
- tclcore moduledoc 0.3.1 (authoring-style only): the after id-shape harvest is consumed via tstr placeholders with the variable in the argdoc namespace (the defspace), replacing the interim string-map token - the module now showcases the neater placeholder style documented in punk::args 0.11.1, with string map reserved for when build-time substitution is genuinely necessary. Behaviour unchanged.
## [0.12.17] - 2026-07-13
- punk::args 0.11.1 (documentation-only): the define help now documents the two definition-registration styles (direct define vs lazy PUNKARGS/register::NAMESPACES registration used by module templates and moduledocs) and the tstr interpolation rules - display-field deferral, parse-field expansion at first resolve, the defspace rule (argdoc child namespace wins when present), the silent-literal fallback for unresolvable placeholders, and the safe patterns for load-time-computed values.
## [0.12.16] - 2026-07-13
- tclcore moduledoc 0.3.0: 'after cancel <word>' and 'after info <id>' now discriminate by the after-id shape, harvested from the running interpreter at load time (user-directed). 'i after cancel someid' resolves cleanly to the cancel-script form exactly as real Tcl treats it (silent script-match no-op), and only genuinely id-shaped words ('after#N') report the cancelid/cancelscript ambiguity - the junction real Tcl itself resolves by id liveness at runtime. Parity pins added (tclcoreparity.test) including the accepted dead-id over-acceptance boundary.
## [0.12.15] - 2026-07-13
- G-041 doc surface: the help system presents the command form matching the supplied words - 'i after cancel <id>' presents the cancel form's argument table (candidates are named when no form or several forms match, e.g 'after cancel someid' reports the genuine cancelid/cancelscript ambiguity of the documented forms), and 's after cancel someid' underlines the matching synopsis line(s) (punk::ns 0.5.0). punk::args 0.11.0: the documented @form -synopsis override now renders in synopsis output, and candidate-form ranking recognises -choices discriminators (the tclcore models' subcommand words) via the shared choice resolver.
## [0.12.14] - 2026-07-13
- punk::args 0.10.0 (G-041 increment): multi-form argument definitions now parse by automated form selection - punk::args::parse without -form attempts every form and auto-selects the one that cleanly matches ('lseq'-style multiform commands no longer fail with the first form's error when the arguments fit a later form). No matching form raises an error naming each candidate form's failure (best candidate first); arguments matching several forms raise an error naming them, with -form (which now accepts the documented list of form names/indices) restricting to the intended form. Parse results and parse_status report the selected form and every candidate's status ('form'/'formstatus' keys).
## [0.12.13] - 2026-07-13
- G-051 achieved: punk::ns 0.4.0 - the help system's doc walk now accepts the same choice-word abbreviations parsing accepts: 'i string is tr' resolves to the 'string is true' documentation exactly as 'string is tr 1' executes (unique prefixes, aliases, nocase; ambiguous/denied/unknown words stay at the parent exactly when parse rejects them). cmdinfo also reports the truthful cmdtype 'doconly' (was 'notfound') for documentation-only levels such as 'string is <class>' and documented TclOO method docids. punk::lib 0.4.1 adapts its script analysis to the new value with identical behaviour.
## [0.12.12] - 2026-07-12
- punk::ns 0.3.0: 'i <cmd>' with no argument words again signals when the command cannot be called bare - e.g 'i if' shows "Bad number of trailing values for if. Got 0 values. Expected at least 2" in the error scheme above the usage table (regression vs older builds, reported by the user). The G-046-era suppression of that render existed because the old message carried internal-looking attribution ("for punk::args::parse ..."); the G-049 -caller attribution fixed the message wording, so the suppression was reversed rather than reworded - the original 'i string is' complaint case now renders an accurate "Bad number of leading values for string is ..." instead of being hidden.
## [0.12.11] - 2026-07-12
- G-071 achieved: punk::args 0.9.0 allocation choice screen - optional arguments with restricted choice sets no longer greedily consume non-choice words at allocation time, so noise-word grammars parse correctly with the noise word omitted. 'i lseq 0 10 2' (and the '0 10 by 2' / '1 5 by 0' shapes) now parse and render correctly against the tclcore moduledoc; genuinely invalid arglists report a plain excess-values error instead of blaming an unrelated optional argument. Correction recorded: parse_status already accepted -form before the withid/withdef tail per its documented synopsis - the earlier 'missing -form' finding was an argument-order mistake, now pinned by test.
## [0.12.10] - 2026-07-12
- punk::args 0.8.3 (G-071 increment): silenced four more unconditional debug lines that printed to stderr during normal argument parsing whenever an optional element was skipped (visible when using 'i'/help display against commands modelled with noise-word arguments such as if and lseq). New allocation characterization testsuite pins the optional-element mis-allocation ('lseq 0 10 2'-class failures) as GAP tests ahead of the allocator fix.
## [0.12.9] - 2026-07-12
- punk::args 0.8.2: fixed stray debug output ("checking tp ... against value ...") printed to the console when parsing multi-element clause arguments (e.g. 'i try ...' style usage against the tclcore moduledoc). Found during G-041 prework probing of if/switch/try/lseq real-vs-model divergence; findings recorded in the G-041 and G-055 goal detail files.
## [0.12.8] - 2026-07-12
- G-045 achieved: punk::args 0.8.1 documents the definition container quoting rules in the punk::args::define help (braced values fully literal; double-quoted values with Tcl backslash semantics at record parse while $ and [] stay literal; the backslash-escaped tstr placeholder idiom). This completes the definition authoring ergonomics goal: @cmd -unindentedfields honoured (0.6.1), -& record continuation (0.7.0), @normalize block-form indent normalization with the help-system definitions as consumer proof (0.8.0 / punk 0.2.5), quoting documentation (0.8.1).
## [0.12.7] - 2026-07-12
- G-045 (increment): punk::args 0.8.0 adds the bare @normalize directive - opts a definition into indent normalization of block-form multi-line field values (structural leading newline dropped, content re-based to the file-style 4-space continuation convention, relative indents preserved, -unindentedfields exempt, no-op on conforming file-style definitions). Intended for constructed (string-built) definitions. punk 0.2.5: the help-system definitions (::punk::help / help_chunks) convert to indented block authoring under @normalize; 'i help' rendering unchanged.
## [0.12.6] - 2026-07-12
- G-045 (increment): punk::args 0.7.0 adds the -& record-continuation token - an unquoted trailing -& on a definition record line continues the record on the next line, assembling byte-identically to the equivalent backslash continuation. Intended for constructed (string-built) definitions, where backslash-newline is consumed by the building code's own quoting. Brace a literal trailing -& value ({-&}); -& elsewhere on a line or inside braced/quoted multi-line values is ordinary data. Backslash-continuation authoring is unchanged.
## [0.12.5] - 2026-07-12
- G-045 (increment): 'i help' usage table alignment fixed (punk 0.2.4) - the Description block's continuations no longer render indented +12 relative to the first line, and the topic argument's help first line no longer renders +4 relative to its continuations. ::punk::helptopic::define_docs authors help text at the left margin with -unindentedfields {-help} on the @cmd and topic lines. Help text content unchanged.
## [0.12.4] - 2026-07-12
- G-045 (increment): punk::args 0.6.1 - the @cmd directive now honours -unindentedfields for -help, so command help authored at the left margin renders its first line flush with continuations in help/usage displays (previously the option was accepted on @cmd but ignored). No shipped definitions used it yet, so existing help rendering is unchanged; the define documentation states where -unindentedfields is valid.
## [0.12.3] - 2026-07-12
- punk::args 0.6.0 synced into the common vfs payload (_vfscommon.vfs): help/usage synopses render small restricted choice sets as literal alternates (module change committed earlier as c309ed47; this ships it in built kits).
## [0.12.2] - 2026-07-12
- G-039 fix: an interactive shell orphaned by its hosting console dying (killed conhost/terminal) previously spun ~2 CPU cores forever — the Tcl 9 windows console driver never delivers the dead-console state to the script level as a fileevent (tclWinConsole.c `ConsoleEventProc` only notifies on buffered data) while its reader thread busy-loops on the persistent channel error. punk::repl 0.5.0 adds `repl::console_watchdog`, a 5s liveness poll (read-only GetConsoleMode via `chan configure -inputmode`) armed only for a tcl9 console input channel on the process-default console on windows: on a dead console it closes the input channel (stopping the driver's reader thread) and finishes the repl via the normal eof path, so the orphan exits cleanly within seconds. Piped, foreign-console and tcl 8.6 inputs are unaffected.
## [0.12.1] - 2026-07-11
- G-062 achieved: canonical project license declared as BSD-2-Clause. `LICENSE.txt` added at the repo root with the standard BSD-2-Clause text (copyright Julian Marcel Noble, 2023-2026); `README.md` names BSD-2-Clause and points at `LICENSE.txt`; root `AGENTS.md` Repo-wide Notes names the license precisely; `punkproject.toml` `[project]` carries `license = "BSD-2-Clause"`. Repo-sweep found no top-level license claims contradicting it (vendored library licenses and module-level generated docs are not project-level claims).

19
GOALS-archive.md

@ -38,10 +38,21 @@ Acceptance: (reworked 2026-07-08 after the root cause was found) the wedge mecha
Scope: src/make.tcl (new or extended step), src/vendorlib_tcl8 + src/vendorlib_tcl9 (sources), src/vfs/<kit>.vfs/lib_tcl8 + lib_tcl9 (targets), punkcheck tracking
Acceptance: with a newer package version placed under src/vendorlib_tcl9/<platform>, one documented make.tcl invocation updates the participating src/vfs/*/lib_tcl9 trees - installing the new package and removing or explicitly retiring the superseded version (no silent mixed-version provision, per the G-035 concerns) - with punkcheck-tracked provenance; which vfs folders participate is explicitly declared per kit rather than blanket-copied (kit vfs package sets may intentionally differ), with the declaration mechanism recorded (candidate home: the G-024 mapvfs toml); a subsequent `make.tcl project` yields kits loading the new version (provable via the tcludp case: built punk902z reports `package require udp` == 1.0.13 with no udp1.0.12 folder remaining in its vfs); the lib_tcl8 tree gets the same treatment or an explicit exclusion rationale in the goal record.
### G-039 [achieved 2026-07-12] Investigate the orphaned-shell one-core spin on a dead console → detail: goals/archive/G-039-orphan-console-spin.md
Scope: src/modules/punk/repl-999999.0a1.0.tm (console reader/event loop and EOF/error paths), src/modules/punk/console-999999.0a1.0.tm; investigation-first
Acceptance: a documented procedure reproduces the spin on the current kit (e.g. launch an interactive shell in a terminal, then kill/close the hosting terminal or conhost), or the investigation records the attempts made and what evidence would reopen it; the spinning code path is identified (prime suspect: a console read/event loop treating a dead console's immediate EOF/error as retryable without backoff or termination - adjacent to the console-EOF restart path G-038 takes ownership of); after fix/mitigation, the same procedure shows the orphaned process exiting or settling at effectively zero CPU within a short grace period, with live-console interactive behaviour unchanged; the wedge-scoring hazard note (orphans polluting process-liveness checks in test harnesses) is updated to match the outcome.
### G-040 [achieved 2026-07-08] punk::args choice aliasing (-choicealiases) with parse normalization, display folding, and doc-lookup parity → detail: goals/archive/G-040-punkargs-choicealiases.md
Scope: src/modules/punk/args-999999.0a1.0.tm (parse + usage rendering), src/modules/punk/ns-999999.0a1.0.tm (cmdinfo/cmd_traverse choice resolution parity), src/modules/punk-999999.0a1.0.tm (punk::help topic argdoc as first consumer), src/tests/modules/punk/args/testsuites/, src/tests/modules/punk/ns/testsuites/
Acceptance: a definition using -choicealiases parses an alias (and an alias prefix where -choiceprefix allows) to its canonical choice in the parse result, with -choicerestricted 0 passthrough and the deny/reserve lists honoured unchanged; usage display shows one entry per canonical choice with aliases folded (no duplicate rows; -choicelabels attach to the canonical); punk::ns::cmdinfo/cmd_traverse resolve subcommand words to docids with the same outcome as the parser for alias, prefix, denied, reserved and unknown words (the pre-goal characterization tests updated from pinned-GAP to fixed); punk::help's topic definition adopts the feature so `i help` lists one entry per registered topic while `help h`/`help e` still fall through to command lookup; definitions without -choicealiases behave unchanged (existing punk::args and punk::ns suites pass).
### G-041 [achieved 2026-07-13] punk::args multi-form matching: automated form selection for parsing and documentation → detail: goals/archive/G-041-punkargs-form-matching.md
Scope: src/modules/punk/args-999999.0a1.0.tm (parse form selection, arg_error/usage form marking), src/modules/punk/ns-999999.0a1.0.tm (cmdhelp/synopsis closest-form indication), src/tests/modules/punk/args/testsuites/
### G-045 [achieved 2026-07-12] punk::args definition authoring ergonomics: record continuation, @cmd unindented fields, constructed-definition normalization → detail: goals/archive/G-045-punkargs-authoring-ergonomics.md
Scope: src/modules/punk/args-999999.0a1.0.tm (record parsing in resolve, tstr interplay, arg_error @cmd rendering), src/tests/modules/punk/args/testsuites/ (rendering.test/defquoting.test as the safety net), src/modules/punk-999999.0a1.0.tm (::punk::helptopic::define_docs de-hacked as the consumer proof)
Acceptance: a definition using the chosen record-continuation mechanism parses identically to its backslash-continuation equivalent (existing definitions unchanged - continuation is additive), with the token's collision rules documented and an escape/rejection story for values that legitimately match it; @cmd -help/-summary honour -unindentedfields (the rendering.test GAP rendering_unindentedfields_cmd_help_GAP flips to aligned); a constructed definition can request whole-block normalization so embedded continuation indentation behaves as in file-style definitions (the rendering_constructed_def_indent_characterization expectations updated to the chosen semantics), and ::punk::helptopic::define_docs drops its manual pre-normalization to prove it; the quoting rules from defquoting.test appear in the punk::args::define -help documentation; the full punk::args suite (128 tests incl. the rendering invariants: nesting independence, relative-indent preservation) passes with GAP tests flipped, none weakened.
### G-046 [achieved 2026-07-10] punk::args deferred -help resolution (parse-time performance + reentrancy) and rendering/value-shape fixes → detail: goals/archive/G-046-punkargs-deferred-help-and-fixes.md
Scope: src/modules/punk/args-999999.0a1.0.tm (resolve/get_dict: display-field deferral, dynamic-cache subst path, prefix writeback, string renderer, cmdhelp-facing messages), src/modules/punk/ansi-999999.0a1.0.tm (mark_columns argdoc as the reentrancy/perf testbed), src/tests/modules/punk/args/testsuites/ (GAP tests flip; perf verification)
Acceptance: parsing/argument resolution provably skips -help expansion (a definition whose -help contains a ${[...]} that would error or record its invocation shows the substitution did NOT run during a parse-only path, only for help display); first parse of punk::ansi::mark_columns drops from ~4s to well under a second with 'i punk::ansi::mark_columns' still rendering the embedded example, and a -help that parses its OWN definition id resolves or errors cleanly rather than looping; first-parse timing improves for at least one other heavily documented command (recorded in the detail file); rendering_atdynamic_multiline_help_insertion_GAP flips to all-aligned; choicegroups_imap_prefix_listwrap_GAP flips to shape-identical (prefix input yields the same plain string as exact input); the -return string renderer's cmd-help continuations align under the first line with relative indents preserved (rendering_string_renderer_characterization updated); the 'Bad number of leading values...' prefix shown by goodargs parsing in 'i string is'-style output is reworded or suppressed for the usage-display path; full punk::args and punk::ns suites pass with no non-GAP expectations weakened.
@ -50,6 +61,10 @@ Acceptance: parsing/argument resolution provably skips -help expansion (a defini
Scope: src/modules/punk/args-999999.0a1.0.tm (arg_error, parse error dispatch, colour-scheme handling), src/modules/punk/ns-999999.0a1.0.tm (cmdhelp), src/tests/modules/punk/args/testsuites/args/usagemarking.test, src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test
Acceptance: cmdhelp -return dict distinguishes an incomplete, a fully-valid and an invalid argument set via per-argument statuses (received/ok/bad + overall scheme/message/form) with the structure documented; the table and string renderers derive their marking from that same structure, with rendered output unchanged except where the pinned GAP tests flip: badarg marking covers type/allocation failures not just choice violations (cmdhelp_GAP_no_badarg_marking_for_failed_typed_value), an explicit -scheme is honoured on the parse-failure path (cmdhelp_GAP_explicit_scheme_ignored_on_failure), the failure message names the queried command instead of cmdhelp's internal parse source line (cmdhelp_GAP_errormsg_leaks_internal_source), and scheme rendering no longer depends on or mutates shared colour state - the documented -scheme choice value 'nocolour' takes effect and repeated renders of the same call are identical regardless of prior scheme renders (usagemarking_GAP_scheme_nocolour_renders_with_leftover_colours, usagemarking_GAP_dash_nocolour_leaks_into_shared_array, usagemarking_GAP_dash_nocolour_leak_affects_later_info_render); all non-GAP characterization tests in usagemarking.test and cmdhelp.test pass unchanged.
### G-051 [achieved 2026-07-13] cmdinfo truthful cmdtype for doc-only pseudo-commands and space-form docid prefix parity → detail: goals/archive/G-051-cmdinfo-pseudo-and-prefix.md
Scope: src/modules/punk/ns-999999.0a1.0.tm (cmdinfo, cmd_traverse), src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test, src/tests/modules/punk/ns/testsuites/ns/cmdflow.test
Acceptance: the pinned GAP tests flip: cmdhelp_GAP_pseudo_command_cmdtype_notfound and cmdhelp_GAP_string_is_true_pseudo report the new cmdtype with the docid unchanged, cmdhelp_GAP_spaceform_docid_prefix_not_honoured and cmdhelp_GAP_string_is_prefix_not_honoured resolve the child docid from a prefix exactly when parse accepts that prefix (honouring -choiceprefix, -choiceprefixdenylist, -choiceprefixreservelist and -choicealiases per G-040 parity); consumers of cmdinfo's cmdtype (cmdhelp, synopsis, eg) handle the new value with no behaviour change for real commands; cmdflow.test and the non-GAP cmdhelp.test tests pass unchanged.
### G-054 [achieved 2026-07-11] tclcore moduledoc: runtime-harvested 'string is' class choices with cross-version behavioural parity pins → detail: goals/archive/G-054-tclcore-stringis-harvest.md
Scope: src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (+ tclcore-buildversion.txt), src/tests/modules/punk/args/testsuites/args/ (new parity test), TEMP_REFERENCE/tcl9 (read-only reference)
Acceptance: parse/parse_status against ::tcl::string::is and its per-class virtual ids agrees with the real interpreter's error-vs-ok outcome for a pinned probe matrix (missing args, trailing flag-like str word, option/class unique-prefix acceptance and ambiguity rejection, unknown option/class, -failindex var consumption leaving no str, per-version class presence: dict, unicode) on Tcl 9.0.x and 8.6; the rendered choices show only classes the running interp accepts; the parity test derives expectations from the live interpreter (not version arithmetic) and passes under both; existing args/tclcore suites pass; tclcore buildversion bumped with changelog.
@ -65,3 +80,7 @@ Acceptance: a documented probe helper yields a wsl_linux_available constraint wh
### G-062 [achieved 2026-07-11] Canonical project license: BSD-2-Clause LICENSE file with SPDX-identified references → detail: goals/archive/G-062-project-license-file.md
Scope: LICENSE.txt (new, repo root), README.md, punkproject.toml ([project] license field), AGENTS.md (Repo-wide Notes license mention)
Acceptance: LICENSE.txt exists at the repo root containing the standard BSD-2-Clause text with a real copyright line; README.md names BSD-2-Clause and points at LICENSE.txt; root AGENTS.md Repo-wide Notes names the license precisely; punkproject.toml [project] carries license = "BSD-2-Clause"; a repo-sweep for top-level license claims finds none contradicting it (sweep result recorded here).
### G-071 [achieved 2026-07-12] punk::args value-allocation correctness for optional elements (lseq-class arglists) + parse_status -form → detail: goals/archive/G-071-punkargs-optional-allocation.md
Scope: src/modules/punk/args-999999.0a1.0.tm (get_dict value allocation, private::get_dict_can_assign_value, parse_status), src/tests/modules/punk/args/testsuites/args/ (new allocation characterization suite), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (::lseq as the proving consumer)
Acceptance: a new allocation characterization suite drives the lseq range matrix under explicit -form range - '0 10', '0 10 2', '0 10 by 2', '0 to 10 2', '0 .. 10 by 2', '1 5 by 0' parse per the lseq.n grammar (the three currently-failing cases fixed) and '0 10 2 4' still fails - plus reduced fixtures isolating the shape (optional choice value between required values + trailing optional-member clause) independent of the moduledoc; the ::if noise-word cases (mid-clause ?literal(then)?, clause-leading ?literal(else)?) keep passing - no regression to optional clause members generally; a genuinely invalid arglist's error names the failing element (the '..|to' misblame case pinned fixed); punk::args::parse_status accepts -form (single form name/index at minimum, consistent with parse) with its status structure reporting the form used; full punk::args and punk::ns suites pass with no expectations weakened; before/after results for the probe matrix recorded in this file.

56
GOALS.md

@ -194,14 +194,6 @@ Detail: goals/G-035-mixed-tm-pkgindex-provision.md
Scope: src/lib/app-punkshell/punkshell.tcl (eof-restart handover), src/modules/punk/repl-999999.0a1.0.tm (eof-restart done-mode that skips codethread teardown), src/modules/punk/repl/codethread-999999.0a1.0.tm (as touched)
Detail: goals/G-038-piped-session-continuity.md
### G-039 [proposed] Investigate the orphaned-shell one-core spin on a dead console
Scope: src/modules/punk/repl-999999.0a1.0.tm (console reader/event loop and EOF/error paths), src/modules/punk/console-999999.0a1.0.tm; investigation-first
Detail: goals/G-039-orphan-console-spin.md
### G-041 [proposed] punk::args multi-form matching: automated form selection for parsing and documentation
Scope: src/modules/punk/args-999999.0a1.0.tm (parse form selection, arg_error/usage form marking), src/modules/punk/ns-999999.0a1.0.tm (cmdhelp/synopsis closest-form indication), src/tests/modules/punk/args/testsuites/
Detail: goals/G-041-punkargs-form-matching.md
### G-042 [proposed] Subshell-declared help topics via punk::config with defined shadowing policy
Scope: src/modules/punk-999999.0a1.0.tm (::punk::helptopic registry), src/modules/punk/config-0.1.tm (stored declaration source), src/modules/punk/repl-999999.0a1.0.tm (subshell entry/exit hooks)
Detail: goals/G-042-subshell-help-topics.md
@ -214,10 +206,6 @@ Detail: goals/G-043-subshell-definition-plugins.md
Scope: src/modules/punk/repl-999999.0a1.0.tm (editbuf/reader integration, provider seam), src/modules/punk/args-999999.0a1.0.tm + src/modules/punk/ns-999999.0a1.0.tm (introspection surfaces as consumed), src/modules/punk/console-999999.0a1.0.tm (rendering)
Detail: goals/G-044-repl-command-completion.md
### G-045 [proposed] punk::args definition authoring ergonomics: record continuation, @cmd unindented fields, constructed-definition normalization
Scope: src/modules/punk/args-999999.0a1.0.tm (record parsing in resolve, tstr interplay, arg_error @cmd rendering), src/tests/modules/punk/args/testsuites/ (rendering.test/defquoting.test as the safety net), src/modules/punk-999999.0a1.0.tm (::punk::helptopic::define_docs de-hacked as the consumer proof)
Detail: goals/G-045-punkargs-authoring-ergonomics.md
### G-047 [proposed] Declared primary VCS in punkproject.toml with per-developer commit-target override
Scope: punkproject.toml (schema), punkproject.local.toml (new, uncommitted per-checkout override), root AGENTS.md (Commit Conventions (any VCS) section), .gitignore + .fossil-settings/ignore-glob (ignore rules for the local file, per the coexistence contract), src/project_layouts/ (layout template payload - default values and ignore seeding only; project.new validation is follow-on work)
Detail: goals/G-047-declared-primary-vcs.md
@ -230,10 +218,6 @@ Detail: goals/G-048-textblock-table-punkargs.md
Scope: src/modules/punk/ns-999999.0a1.0.tm (synopsis), src/modules/punk/args-999999.0a1.0.tm (synopsis renderer), src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test (synopsis pins)
Detail: goals/G-050-synopsis-validity-marking.md
### G-051 [proposed] cmdinfo truthful cmdtype for doc-only pseudo-commands and space-form docid prefix parity
Scope: src/modules/punk/ns-999999.0a1.0.tm (cmdinfo, cmd_traverse), src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test, src/tests/modules/punk/ns/testsuites/ns/cmdflow.test
Detail: goals/G-051-cmdinfo-pseudo-and-prefix.md
### G-052 [proposed] TclOO method-level autodef documentation
Scope: src/modules/punk/ns-999999.0a1.0.tm (generate_autodef oo branches, cmd_traverse), src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test
Detail: goals/G-052-oo-method-autodef.md
@ -269,3 +253,43 @@ Detail: goals/G-063-package-license-tracking.md
### G-064 [proposed] lib.search machine-parsable returns (dict/json) and license surfacing option
Scope: src/modules/punk/mix/commandset/loadedlib-999999.0a1.0.tm, src/tests/modules/punk/mix/ (new testsuite), G-063 mapping module (as consumed)
Detail: goals/G-064-libsearch-machine-returns.md
### G-065 [proposed] Declarative vendoring: toml-declared external packages with pinning, provenance and binary gating
Scope: punkproject.toml or sibling vendor manifest (schema - settled in the work), src/modules/punk/mix/ (vendor-sync command surface), src/vendorlib/ + src/vendormodules/ + src/vfs/ (materialization targets), src/make.tcl (integration)
Detail: goals/G-065-declarative-vendoring.md
### G-066 [proposed] pkgIndex.tcl-to-.tm repackaging: lib.copyasmodule expansion with embedded metadata and distribution-unit tracking
Scope: src/modules/punk/mix/commandset/loadedlib-999999.0a1.0.tm (lib.copyasmodule), src/modules/punk/mix/ (modpod/zipkit tooling as needed), src/tests/modules/punk/mix/ (converter testsuite)
Detail: goals/G-066-pkgindex-tm-repackaging.md
### G-067 [proposed] Module artifact channel: publish prepared .tm modules to and retrieve from configurable artifact servers
Scope: src/make.tcl or punk::mix dev commandset (publish/retrieve surface - settled in the work), user-config (consent flag + server list, G-006 pattern), src/vendormodules/ + src/vendorlib/ (retrieval targets)
Detail: goals/G-067-module-artifact-channel.md
### G-068 [proposed] Agent-assisted moduledoc generation workflow for vendored third-party libraries
Scope: goals/G-068-vendored-moduledoc-workflow.md (workflow doc), src/modules/punk/args/moduledoc/ (generated companion modules), src/tests/modules/punk/args/ (probe verification where feasible)
Detail: goals/G-068-vendored-moduledoc-workflow.md
### G-069 [proposed] Dev-time lint: cross-check punk::args record splitting against tclparser where the binary is available
Scope: lint surface (location settled in the work: punk::args dev helper, dev commandset, or scriptlib/developer script), src/modules/punk/args-999999.0a1.0.tm (split_definition_records as consumed, no new dependency), src/tests/modules/punk/args/ (capability-gated suite)
Detail: goals/G-069-splitter-tclparser-lint.md
### G-070 [proposed] Pure-Tcl tclparser: parse-command API fallback with behavioural parity against the C library
Scope: src/modules/punk/lib-999999.0a1.0.tm (tclparser_tcl stub + dispatch; new module if size warrants - decided in the work), src/tests/modules/punk/lib/ (parity + fallback suites), TEMP_REFERENCE/ (tclparser reference source, user-provided, read-only)
Detail: goals/G-070-pure-tcl-tclparser.md
### G-072 [proposed] punk::args compound clause types: named alternates with per-element typing and per-alternate arity (try-class handlers)
Scope: src/modules/punk/args-999999.0a1.0.tm (type-expression parsing, clause allocation, synopsis/help renderers), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (::try as proving consumer; ::if/::switch as touched), src/tests/modules/punk/args/testsuites/args/
Detail: goals/G-072-punkargs-compound-clause-types.md
### G-073 [proposed] punk::args unavailable choices: displayed with notes and prefix-reserving, but rejected with a tailored message
Scope: src/modules/punk/args-999999.0a1.0.tm (spec key, choiceword_match pool, choices rendering in arg_error table+string renderers, validation message), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm ('string is' forward-class adoption + per-class virtual id), src/tests/modules/punk/args/testsuites/args/ (new suite + tclcoreparity.test exemption)
Detail: goals/G-073-punkargs-unavailable-choices.md
### G-074 [proposed] punk::args multiform ambiguity analysis: on-demand form-overlap detection with sanctioned-overlap annotation
Scope: src/modules/punk/args-999999.0a1.0.tm (analysis command, @form sanction key as decided in the work), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (::after/::lseq as proving consumers, sanction adoption), src/tests/modules/punk/args/testsuites/args/ (new suite)
Detail: goals/G-074-punkargs-multiform-ambiguity-lint.md
### G-075 [proposed] punk::args (package) ids: working lookup and a user-facing package documentation surface
Scope: src/modules/punk/args-999999.0a1.0.tm (id lookup/update_definitions prefix handling, usage/arg_error rendering of package-level ids), src/modules/punk/ns-999999.0a1.0.tm (cmdhelp/help-system surface), src/modules/punk/mix/#modpod-templates-999999.0a1.0/templates/modules/template_module-0.0.4.tm (template block as touched/verified), src/tests/modules/punk/args/testsuites/args/ + src/tests/modules/punk/ns/testsuites/ns/ (new coverage)
Detail: goals/G-075-punkargs-package-ids.md

10
goals/G-038-piped-session-continuity.md

@ -151,6 +151,14 @@ Risks / interactions:
G-002 (non-nested subshell architecture - same family of repl lifecycle decoupling);
G-031 (componentized boot); G-001/G-011 (console object rebinding on reopen);
G-015 (script subcommand unaffected - it never drops to interactive).
- G-039 (archived) root-caused the dead-console orphan spin (Tcl 9 tclWinConsole.c never
delivers a dead console as a fileevent; reader thread busy-loops) and added
repl::console_watchdog (punk::repl 0.5.0), which closes a dead process-console input
and finishes the repl with done={eof <chan>} - a third producer of the eof done-value
this goal's caller-driven restart must handle (its console-reopen failure branch shares
the same decision point: CONIN$ unopenable => exit cleanly, never retry-loop). The
watchdog's arming is per repl::start frame; the restart variant must re-arm it for the
renewed channel. See goals/archive/G-039-orphan-console-spin.md.
### Post-restart console-query harness (2026-07-08)
@ -181,4 +189,4 @@ object's channels). State observed pre-fix, for reference: repl thread post-rest
`stdin` eof=1 (the exhausted pipe) alongside the new `file...` CONIN$ channel; the code
interp's stdin is a live console channel (utf-16, -inputmode present); anchored instances
known to the repl thread: `default` (anchored to the dead pair).
- Archived-goal references in this file: G-001 achieved 2026-07-11 (goals/archive/G-001-pluggable-console-backends.md);G-007 achieved 2026-07-05 (goals/archive/G-007-console-location-transparency.md);G-015 achieved 2026-07-07 (goals/archive/G-015-script-subcommand-piped-stdin.md);G-036 achieved 2026-07-08 (goals/archive/G-036-tcl9-udp-console-worker-wedge.md).
- Archived-goal references in this file: G-001 achieved 2026-07-11 (goals/archive/G-001-pluggable-console-backends.md);G-007 achieved 2026-07-05 (goals/archive/G-007-console-location-transparency.md);G-015 achieved 2026-07-07 (goals/archive/G-015-script-subcommand-piped-stdin.md);G-036 achieved 2026-07-08 (goals/archive/G-036-tcl9-udp-console-worker-wedge.md);G-039 achieved 2026-07-12 (goals/archive/G-039-orphan-console-spin.md).

49
goals/G-039-orphan-console-spin.md

@ -1,49 +0,0 @@
# G-039 Investigate the orphaned-shell one-core spin on a dead console
Status: proposed
Scope: src/modules/punk/repl-999999.0a1.0.tm (console reader/event loop and EOF/error paths), src/modules/punk/console-999999.0a1.0.tm; investigation-first
Goal: the observed failure mode - an interactive punk902z left running after its hosting terminal/console went away spins roughly a full core indefinitely (observed 2026-07-08: a 37-minute orphan with a single hard-looping thread) - is reliably reproduced and root-caused, then fixed or mitigated so a shell whose console dies exits or reaches zero-CPU idle cleanly.
Acceptance: a documented procedure reproduces the spin on the current kit (e.g. launch an interactive shell in a terminal, then kill/close the hosting terminal or conhost), or the investigation records the attempts made and what evidence would reopen it; the spinning code path is identified (prime suspect: a console read/event loop treating a dead console's immediate EOF/error as retryable without backoff or termination - adjacent to the console-EOF restart path G-038 takes ownership of); after fix/mitigation, the same procedure shows the orphaned process exiting or settling at effectively zero CPU within a short grace period, with live-console interactive behaviour unchanged; the wedge-scoring hazard note (orphans polluting process-liveness checks in test harnesses) is updated to match the outcome.
## Context
Observation (2026-07-08, during the G-036 investigation): a punk902z process from an earlier
interactive session (started ~37 minutes prior; its hosting terminal presumed closed) was
found consuming CPU continuously - ~1550 CPU-seconds accumulating at roughly +0.5-1
core-seconds per wall second, with exactly ONE thread in Running state (1546s of the total)
and every other thread idle in normal waits. The process had to be killed manually. It also
polluted the wedge-scoring checks of the day (process-liveness was being used as a hang
signal), which is how it was noticed.
Not diagnosed at the time (killed to unblock the G-036 work); no dump was taken. The
suspicion: when the hosting console goes away, a console channel read/event path returns
immediately (EOF or error) and the surrounding loop retries without backoff or a
give-up-and-exit decision. Candidate sites: the repl reader loop, the EOF branch behaviour
when reopen/restart fails or loops (note tcl_interactive would be true for a real console,
enabling the `after 1 reopen_stdin` path - see the race notes in the G-038 detail file), or
punk::console query/read helpers.
## Approach
1. Reproduce: launch an interactive shell in a disposable terminal and kill the host
(close the tab/window; `Stop-Process` on the terminal; for classic conhost, killing
conhost.exe; also try Windows Terminal vs conhost - behaviour may differ). Watch the
orphan's CPU and thread states (`(Get-Process punk902z).Threads` sorted by
TotalProcessorTime). Try both idle-at-prompt and mid-command states at kill time.
2. If reproduced: procdump + WinDbgX scripted stack capture of the spinning thread (the
G-036 detail file documents the working tooling pipeline and pitfalls - WinDbgX process
name is DbgX.Shell, cdb is ACL-buried, `-z/-p -c -logo` scripting works). With the spin
thread's stack, map to the retry loop and decide fix: treat dead-console EOF/error as
terminal (exit per PUNK_PIPE_EOF-like policy), or add backoff + detection.
3. Coordinate with G-038: its caller-driven restart owns the console-EOF path; the dead
console case is the failure branch of the same decision point (reopen CONIN$ fails or
the console is gone entirely) and should be designed together.
## Notes
- Related: G-038 (console-EOF restart ownership, reopen_stdin race notes), G-036 (dump/
debugging tooling pipeline), tcl86-console-parked-read prior art (different mechanism,
same neighbourhood).
- Harness hygiene until fixed: verify no stray punk902z processes before scoring any
liveness-based test run.
- Archived-goal references in this file: G-036 achieved 2026-07-08 (goals/archive/G-036-tcl9-udp-console-worker-wedge.md).

87
goals/G-041-punkargs-form-matching.md

@ -1,87 +0,0 @@
# G-041 punk::args multi-form matching: automated form selection for parsing and documentation
Status: proposed
Scope: src/modules/punk/args-999999.0a1.0.tm (parse form selection, arg_error/usage form marking), src/modules/punk/ns-999999.0a1.0.tm (cmdhelp/synopsis closest-form indication), src/tests/modules/punk/args/testsuites/
Goal: for multi-form definitions punk::args determines which form(s) an argument list matches - parse without -form attempts all permitted forms instead of effectively form 0, -form accepts the documented list-of-forms restriction, and the documentation surface indicates the match ('i after cancel <id>' presents the cancel form; 's after cancel someid' marks the closest synopsis) - with explicit single-form restriction retained for callers that require it.
Acceptance: parsing a multiform definition (after-like fixture) without -form succeeds when the args match exactly one form (the pinned GAP tests in forms.test flip to auto-selected results); an argument list matching no form (or several) produces an error naming the candidate forms rather than a form-0 type error; -form with a list of form names/indices restricts parsing to that subset (currently an 'Expected int 0-N or one of ...' error); 'i <cmd> <args...>' and synopsis output indicate the best-matching form(s) for supplied args; explicit -form <single-name-or-index> behaviour is unchanged; the full punk::args and punk::ns suites pass.
## Context
Multi-form definitions (@form directive) describe commands whose argument sets are
orthogonal - the motivating example is the Tcl builtin `after`, documented with several
forms (`after ms ?script...?`, `after cancel id`, `after idle script...`, ...). The
definition and display side works: form_names are recorded, `punk::ns::synopsis` renders
every form (## FORM n headers), and `punk::args::parse -form <name-or-index>` parses
against an explicitly chosen form.
What is missing (characterized 2026-07-08 with an after-like fixture, probe results
pinned as GAP tests in src/tests/modules/punk/args/testsuites/args/forms.test):
- `punk::args::parse` without -form (default `*`) does NOT attempt the other forms:
arguments that match only a non-zero form (e.g. `cancel after#1`) fail with form 0's
type/arity error ("Bad number of values ... Not enough remaining values", the leading
word rejected by form 0's int type). Effectively `*` parses form 0 only.
- `-form` is documented as "Restrict parsing to the set of forms listed" and typed as a
list, but a list of two form names errors with "invalid -form value ... Expected int
0-N or one of '<names>'" - only a single name or index is accepted.
- Documentation surface: `i after cancel <id>` renders help for the default form rather
than determining that the cancel form is the closest match, and `s after cancel someid`
lists all synopses without marking the matching one.
- Internal note: punk::args::parse's own definition is multiform (withid/withdef,
discriminated by literal types) but the proc hand-parses its arguments on the happy
path, so the multiform selection machinery is not exercised internally either.
User direction (2026-07-08): there are times when restricting to a particular form is
appropriate and times when automated selection is better - both must remain expressible.
This is an advanced improvement, deferred; the immediate prerequisite work was making the
punk::args test suite comprehensive so the current behaviour is pinned before any
form-selection logic lands.
## Approach (sketch - to be refined when activated)
1. Form candidacy: for -form * (or a list), attempt each permitted form's parse; classify
outcomes (clean match / match-with-defaults / type-or-arity failure). Exactly one
clean match -> auto-select. None or several -> error naming candidate forms (and for
'several', possibly a deterministic preference rule - e.g. first-declared - recorded
when decided).
2. Literal/leading-word discrimination as a fast path: many multiform commands (after,
parse itself) discriminate on a leading literal - a pre-pass on literal/literalprefix
leader types can pick the form without full parse attempts.
3. -form list support: accept a subset of names/indices as documented; single value
behaviour unchanged.
4. Documentation surface: cmdhelp/arg_error accept the supplied trailing args (cmdhelp
already parses them for goodargs marking) and use the same candidacy logic to pick or
rank forms; synopsis display marks the matching form(s).
5. Error-message quality: a no-form-matches error should show per-form failure reasons
compactly rather than only form 0's.
Downstream consumer note (2026-07-08, G-044): the interactive command-completion/hinting
goal consumes form candidacy on PARTIAL argument lists - as the user types, the hinting
display wants "which forms are still compatible with the words so far" - so the candidacy
mechanism should be exposed as an API that accepts an incomplete argument list and returns
per-form compatibility (not only an all-or-nothing full parse). Design for that consumer
when shaping step 1.
## Alternatives considered
- Documenting alternate forms as separate subhelp choiceinfo entries (the trace-style
pattern) - works only when a literal subcommand word exists and fragments a command's
documentation into pseudo-subcommands; rejected as the general answer.
- Requiring callers to always pass -form - status quo for parsing, but unusable for the
interactive doc surface ('i after cancel <id>') where the user supplies args, not form
names.
## Notes
- Probe script: scratchpad formprobe.tcl (2026-07-08); findings encoded in
src/tests/modules/punk/args/testsuites/args/forms.test (GAP-labelled tests reference
this goal and flip when it is implemented).
- Related: G-040 (choice aliasing/doc-lookup parity - same "parser and doc surface must
agree" principle); punk::args::parse -cache interaction with per-form attempts will
need review (cache key already includes selected form).
- Incidental find during the same probing: an uncommented debug `puts stderr
">>>_get_dict_can_assign_value NOT alloc_ok..."` fired on every failed clause type
assignment (punk/args ~l.6186; its happy-path twin was already commented). Fixed
2026-07-08 (punk::args 0.2.3) - noted here because form-attempt logic will exercise
that failure path heavily.
- Archived-goal references in this file: G-040 achieved 2026-07-08 (goals/archive/G-040-punkargs-choicealiases.md).

10
goals/G-044-repl-command-completion.md

@ -40,9 +40,11 @@ arginfo principle: introspection must not run commands to elicit usage).
position. Choice-word matching must reuse the parser's resolution rules (the shared
choice-resolution helper from G-040 (achieved 2026-07-08) is the natural common code; prefix
highlighting from the usage renderer already computes minimal prefixes).
Form handling: before G-041, enumerate all forms' next-position candidates (union)
and show all/form-0 synopses with the limitation noted; when G-041's candidacy API
exists, rank/filter forms by the partial input (G-041's detail file notes the API
Form handling: G-041 (achieved 2026-07-13) shipped the candidacy API this consumes
- punk::args::parse_status reports per-form compatibility for a (partial) argument
list in its formstatus key (status valid|incomplete|invalid per form; the form key
is the matched/best-candidate form) - rank/filter forms by the partial input with
it (G-041's detail file notes the API
should accept partial argument lists for exactly this consumer).
2. Repl integration (raw mode): trigger scheme preserving literal tab - candidates to
evaluate at implementation time (decision recorded here): double-Tab, a dedicated
@ -86,7 +88,7 @@ arginfo principle: introspection must not run commands to elicit usage).
src/tests/modules/punk/ns/testsuites/ns/cmdflow.test (2026-07-08), including ensemble
-parameters handling; the choice semantics by
src/tests/modules/punk/args/testsuites/args/choices.test; forms by forms.test.
- Archived-goal references in this file: G-001 achieved 2026-07-11 (goals/archive/G-001-pluggable-console-backends.md); G-040 achieved 2026-07-08 (goals/archive/G-040-punkargs-choicealiases.md).
- Archived-goal references in this file: G-001 achieved 2026-07-11 (goals/archive/G-001-pluggable-console-backends.md); G-040 achieved 2026-07-08 (goals/archive/G-040-punkargs-choicealiases.md); G-041 achieved 2026-07-13 (goals/archive/G-041-punkargs-form-matching.md).
## Repl behaviour preserve-list + testability findings (2026-07-11, user-directed - pre-refactor ordering)

97
goals/G-045-punkargs-authoring-ergonomics.md

@ -1,97 +0,0 @@
# G-045 punk::args definition authoring ergonomics: record continuation, @cmd unindented fields, constructed-definition normalization
Status: proposed
Scope: src/modules/punk/args-999999.0a1.0.tm (record parsing in resolve, tstr interplay, arg_error @cmd rendering), src/tests/modules/punk/args/testsuites/ (rendering.test/defquoting.test as the safety net), src/modules/punk-999999.0a1.0.tm (::punk::helptopic::define_docs de-hacked as the consumer proof)
Goal: authoring punk::args definitions no longer requires backslash line-continuations or ad-hoc workarounds for multi-line records and constructed definitions - a parser-recognised record-continuation mechanism (candidate: an unquoted trailing -& token, with the detail file recording the collision analysis and the element-count disambiguation alternative), -unindentedfields honoured for @cmd fields, and constructed (string-built) definitions able to opt into the same whole-block indent normalization file-style definitions get - with the container quoting rules (braced=literal, quoted=Tcl backslash semantics, \$\{ escape) promoted from defquoting.test into the define documentation.
Acceptance: a definition using the chosen record-continuation mechanism parses identically to its backslash-continuation equivalent (existing definitions unchanged - continuation is additive), with the token's collision rules documented and an escape/rejection story for values that legitimately match it; @cmd -help/-summary honour -unindentedfields (the rendering.test GAP rendering_unindentedfields_cmd_help_GAP flips to aligned); a constructed definition can request whole-block normalization so embedded continuation indentation behaves as in file-style definitions (the rendering_constructed_def_indent_characterization expectations updated to the chosen semantics), and ::punk::helptopic::define_docs drops its manual pre-normalization to prove it; the quoting rules from defquoting.test appear in the punk::args::define -help documentation; the full punk::args suite (128 tests incl. the rendering invariants: nesting independence, relative-indent preservation) passes with GAP tests flipped, none weakened.
## Context
Drafted 2026-07-09 after the tests-first characterization pass (user direction: implement
tests before changes in this area). The pain points, in the user's framing:
- The define-block syntax deliberately avoids Tcl dict syntax for human readability, which
led to backslash line-continuations and a complex interplay between record parsing in
punk::args::resolve and tstr (especially ${...} expansions keeping whitespace aligned
during multiline substitutions).
- Constructed (string-built) definitions - ::punk::helptopic::define_docs and
punk::args::ensemble_subcommands_definition are the in-tree examples - have poor
ergonomics for getting indentation right: unlike file-style definitions they receive no
whole-block indent normalization (proven: rendering.test
rendering_constructed_def_indent_characterization - continuations at exactly 4 spaces
align, other depths leak), so builders pre-normalize by hand (undent/trim hacks,
\n-relative authoring).
- A key property of normal definitions that must be preserved: they do not depend on the
indentation of the code block they appear in (proven and pinned:
rendering_nesting_independence_plain / _tstr).
- The user's experiment used an ad-hoc `--` flag followed by a braced line-spanning empty
string as a de-facto record continuation - functional but not designed-for, and
unsatisfying.
## The -& record-continuation candidate
The user's proposal: a special token the record parser recognises as record continuation,
e.g. an unquoted `-&` at end of line. Design considerations recorded for implementation:
- Collision risk is low but not zero (a value or flag legitimately spelled -&). Candidate
rules: only an UNQUOTED trailing `-&` at end of a record line counts; and/or use the
element count of the assembled record to disambiguate (an odd element count where pairs
are expected implies the trailing token is a continuation marker, not a value). The
detail decision must include an escape story for a literal trailing -& value (bracing it
suffices if 'unquoted' is part of the rule).
- Detection must happen in the record parser (resolve) before/alongside line splitting -
the same place backslash-continuation assembly effectively happens via Tcl's own parse
of braced blocks today. Unlike backslash-newline, -& survives inside braced blocks of
CONSTRUCTED definitions too - which is precisely why it helps: string-built defs cannot
use backslash-newline ergonomically (it is consumed by the building code's own quoting).
## @cmd -unindentedfields
Accepted but ignored today (arg_error's cmd_info rendering carries an '#unindentedfields ?'
todo; pinned by rendering_unindentedfields_cmd_help_GAP: left-margin-authored @cmd help
renders its first line +4 vs continuations). Honouring it makes left-margin authoring
available for @cmd -help/-summary as it already is for argument -help and -choicelabels.
## Constructed-definition normalization
Options to decide at implementation (record the choice):
- an explicit define option/directive requesting whole-block undent of the supplied text
- or automatic common-prefix normalization when a definition arrives as a single text
block (risk: changing existing constructed defs' rendering - the characterization test
documents current expectations to update deliberately)
::punk::helptopic::define_docs currently pre-normalizes by hand (undent/trim + \n-relative
help strings + -unindentedfields) - it converts to the new mechanism as the proof, and the
user's uncommitted experimental hack in punk.tm is superseded.
## Quoting documentation
defquoting.test pinned the container rules (braced values fully literal - $, [], two-char
\n, bare backslashes; quoted values get Tcl backslash semantics - \n newline, \\ -> \ -
with $/[] still literal; \$\{...\} renders a literal ${...} in tstr-processed blocks).
Promote these rules into the punk::args::define -help documentation so documenters (e.g.
punk::imap4's {\Deleted}/{$MDNSent} choice values) have them stated, not just tested.
## Alternatives considered
- Keeping backslash continuations + manual pre-normalization as the documented way -
rejected: the friction is proven by two in-tree constructed-def workarounds and the
user's punk.tm experiments.
- Tcl dict syntax for definitions - rejected long ago by design (readability).
- The ad-hoc `--` + braced-gap trick as the blessed continuation - rejected: exploits
parser behaviour that was never a contract.
## Notes
- Safety net: src/tests/modules/punk/args/testsuites/args/rendering.test (15 tests:
nesting independence, relative-indent preservation, unindentedfields, constructed-def
characterization, multiline tstr insertions, @dynamic) and defquoting.test (container
quoting rules). GAPs to flip here: rendering_unindentedfields_cmd_help_GAP,
rendering_constructed_def_indent_characterization (expectations updated to chosen
semantics).
- Related: G-046 (deferred -help resolution and rendering fixes - same code
neighbourhood; sequencing between the two goals is free but they touch resolve
together), G-044 (completion consumes the documentation corpus these ergonomics grow).
- Session records 2026-07-09: probe scripts renderprobe*.tcl (scratchpad), the user's
experimental define_docs hack (uncommitted punk.tm working-tree diff at the time of
drafting - superseded by this goal when implemented).
- Archived-goal references in this file: G-046 achieved 2026-07-10 (goals/archive/G-046-punkargs-deferred-help-and-fixes.md).

5
goals/G-052-oo-method-autodef.md

@ -52,5 +52,8 @@ just not wired up as a general method-level (autodef) generation step in the doc
documented, plainmeth undocumented).
- Filters, private methods and `unknown` handlers are marked in choiceinfo today but out
of scope for autodef generation.
- Coordinate the cmdtype reported for method resolutions with G-051's doconly decision.
- G-051 (achieved 2026-07-13 - see goals/archive/G-051-cmdinfo-pseudo-and-prefix.md)
decided the documented-method case ADOPTS the doconly cmdtype (docid-nonempty +
cmdwhich-notfound classification, no method-specific value). Refine here if this
goal's method-level autodef work warrants a distinct method cmdtype.
- Archived-goal references in this file: G-040 achieved 2026-07-08 (goals/archive/G-040-punkargs-choicealiases.md).

5
goals/G-053-punkargs-multiple-ranges.md

@ -62,8 +62,9 @@ rendering gain meaningful bounded-repetition forms (e.g. "0-1", "2-4").
- `-multipleunique` / `-multipleuniqueset` remain the uniqueness knobs and only
make sense for max > 1; they compose with ranges unchanged.
- Related: G-045 (authoring ergonomics), G-046 (parse-time performance - the
canonicalization must not regress the hot path).
- Related: G-045 (authoring ergonomics, achieved 2026-07-12 - see
goals/archive/G-045-punkargs-authoring-ergonomics.md), G-046 (parse-time
performance - the canonicalization must not regress the hot path).
- The runtests `-include-paths` fix (repeatable, accumulate, single-list form still
accepted) shipped independently on 2026-07-10 and does not depend on this goal.
- Archived-goal references in this file: G-046 achieved 2026-07-10 (goals/archive/G-046-punkargs-deferred-help-and-fixes.md).

91
goals/G-055-tclcore-regen-workflow.md

@ -90,4 +90,93 @@ other commands including the multi-form ::after.
side-effect-free invocations of pure builtins (deliberately-invalid calls
harvesting error metadata). This does not relax cmdhelp's rule of never calling
arbitrary commands to elicit usage at query time.
- Archived-goal references in this file: G-049 achieved 2026-07-10 (goals/archive/G-049-punkargs-parse-status-model.md);G-054 achieved 2026-07-11 (goals/archive/G-054-tclcore-stringis-harvest.md).
- 2026-07-12 modelability findings from if/switch/try/lseq real-vs-model probing
(G-041 prework; all in the over-acceptance class - model reports valid where the
real command errors - so parity probes per this workflow's verification gate
would catch them):
- try handler compound types: the current handler clause
{literal(on)|literal(trap) string list string} cannot type the second element
per-alternate (on wants code choices ok/error/return/break/continue/int; trap
wants a pattern list), so 'try {} on bogus {e} {s}' over-accepts. The
moduledoc's own embedded notes at ::try sketch candidate mechanisms (named
compound types with arity indicators, @typeimport, RPN type expressions).
- reserved-word awareness in clause allocation: 'if {0} {b} elseif' over-accepts
(bare trailing word is a valid else-script EXCEPT when it is a reserved
keyword); clause members have no deny-list concept (cf -choiceprefixdenylist
for choices).
- '-' fallthrough constraints (try handler script, switch body): a literal '-'
is invalid in the LAST handler/pattern position - a cross-element constraint
outside per-element typing.
- switch two-argument special case: leading dash words are options only when
more than two arguments are present ('switch -exact {p b}' means
string="-exact") - argument-count-conditional option parsing.
- -type expr performs no validation (syntactically invalid '3+' accepted, as is
the non-expression 'to'); syntax-level checking is the most that is possible
(expressions may reference runtime vars/functions) - decide and record whether
to add it. Consequence under G-041 form candidacy (found 2026-07-13):
'lseq 1 count 5' reports as ambiguous (range + start_count) because the range
form's expr-typed end accepts the word 'count' - real lseq resolves it as the
start_count form. Tightening the expr operands (or making them
version-conditional per TIP 746 below) removes the false candidate.
Probed 2026-07-13 (user suggestion: -type indexexpression instead): a poor
lexical fit for lseq operands on 9.0.3 - indexexpression (lindex-style
int/end/end+-N/M+-N) UNDER-accepts real-valid operands 1e2, 2*3, 6/2,
sqrt(9) and the man page's own {[llength $l]-1} idiom, and OVER-accepts
end/end-1 (real lseq rejects); number|indexexpression repairs only the
number literals. The precise discriminator is expr SYNTAX validation: the
problem words (count/to/end) all fail Tcl's expr PARSER as invalid
barewords while every real-valid shape parses - but a validator must parse
without evaluating (operand exprs may contain command substitution), i.e
tclparser 'parse expr' (G-069/G-070) or equivalent, not catch{expr}. Also
noted: count-position operands reject doubles where start/end accept them
('lseq 1.5' errors, 'lseq 1.5 3.5' works) - a value-dependent constraint no
lexical type expresses.
- ::after cancelid/cancelscript discrimination (user suggestion 2026-07-13:
per-version probe of the id shape + an id type "starting with after#").
Probed on 9.0.3 and 8.6.11: the id shape is after#N on both (hardcoded
"after#%d" in tclTimer.c; harvest at define time anyway via the safe
create+cancel probe 'set id [after 999999 {}]; after cancel $id' - G-054
technique, no event loop needed, no output). NO new type needed: the
existing stringstartswith(after#) type-alternate is validate-live (not just
synopsis display) - typing the cancelid form's id (and the info form's id)
with it makes 'after cancel someid' resolve cleanly to cancelscript,
matching real behaviour (real 'after cancel <non-id>' is a silent
script-match no-op; 'after info <non-id>' errors at runtime - model-reject
vs real-reject stays parity-true). RESIDUAL (truthful): 'after cancel
after#12' remains ambiguous in the model because a script can also be
after#-shaped - real Tcl resolves by id LIVENESS at runtime (tries id
first, falls back to script match), which no static type expresses; the
G-074 sanction then covers exactly that id-shaped witness rather than the
whole form pair. APPLIED 2026-07-13 (tclcore moduledoc 0.3.0, user-directed):
both ids typed stringstartswith(<harvested prefix>) via a build-time
%AFTERIDPREFIX% string map. CORRECTED note for the workflow (the first
record of this wrinkle misattributed it): parse-field tstr ${...} IS
expanded for plain registered PUNKARGS definitions - but in the DEFSPACE,
which is the argdoc subnamespace whenever one exists, even when the
PUNKARGS list lives in the parent namespace (update_definitions evalns
rule); a variable set in the parent is unresolvable there and the param is
left SILENTLY literal (diagnostic gap - a literal ${...} reaching a parse
field is always an authoring error and could warn). Load-time-computed
values work via plain tstr placeholders with the variable set in the
argdoc namespace (the ::after harvest showcases this since tclcore 0.3.1,
user-preferred style), or via build-time substitution (explicit tstr wrap
as ::tcl::string::is; string-map token reserved for when build-time
substitution is genuinely necessary). Parity pins in tclcoreparity.test
including the accepted dead-id divergence.
- TIP 746 (user-flagged 2026-07-12,
https://core.tcl-lang.org/tips/doc/trunk/tip/746.md) removes the expr
behaviour from lseq operands in Tcl 9.1: the lseq model's number|expr operand
types are correct for 9.0 but must become version-conditional (number-only on
9.1+) - the same adaptive-definition technique as the 'string is' class list
(G-054). punkshell's own code already adapted (punk::args 0.3.2
zero_based_posns). Parity probes for lseq must therefore derive expectations
from the live interpreter, not from the man page vintage.
- Doc-direction guidance (user, 2026-07-12) for this workflow: punkshell synopses
are deliberately richer than the underlying man pages - where the source docs
simplify (a single synopsis line hiding genuinely orthogonal usages), the
definition SHOULD split into multiple @form entries even though the man page does
not present them as forms (lseq range/start_count/count and switch
separate/block are the in-tree precedents). This extends the existing
synopsis-translation exception: form structure, like synopsis notation, follows
punkshell's modelling needs rather than the source document's presentation.
- Archived-goal references in this file: G-049 achieved 2026-07-10 (goals/archive/G-049-punkargs-parse-status-model.md);G-054 achieved 2026-07-11 (goals/archive/G-054-tclcore-stringis-harvest.md); G-041 achieved 2026-07-13 (goals/archive/G-041-punkargs-form-matching.md).

8
goals/G-063-package-license-tracking.md

@ -2,8 +2,8 @@
Status: proposed
Scope: src/modules (Meta license headers), src/vendormodules/ + src/vendorlib/ (vendored license recording), punk::mix module templates (%license% seeding), mapping module (new, name TBD), audit surface (src/make.tcl or dev commandset - TBD)
Goal: every package punkshell ships - first-party modules, vendored modules, vendored libs - carries a license indication resolvable to an SPDX identifier, without requiring module authors to know SPDX: authors write familiar names ("BSD", "MIT", "Tcl license") in the existing Meta license header slot and a mapping layer normalizes them; an audit surface reports per-package license posture and loudly distinguishes copyleft/viral licenses and unresolved/unspecified entries, so GPL-family code cannot enter the tree unnoticed.
Acceptance: a mapping facility (punk module, name decided in the work) resolves friendly license names to SPDX ids - tolerant of case/spacing variants, covering at least every value currently present in Meta license headers, and returning a distinct "unresolved" result for unknown strings rather than guessing; an audit command (make.tcl subcommand or dev commandset command, decided in the work) enumerates packages under src/modules, src/vendormodules and src/vendorlib and reports each one's SPDX id, unresolved raw value, or unspecified; a documented copyleft policy list (GPL, AGPL and LGPL families at minimum) is flagged distinctly in audit output; module templates no longer emit the literal %license% placeholder; first-party <unspecified> headers are populated or the remainder listed here as pending with reasons.
Goal: every package punkshell ships - first-party modules, vendored modules, vendored libs - carries a license indication resolvable to an SPDX identifier, without requiring module authors to know SPDX: authors write familiar names ("BSD", "MIT", "Tcl license") in the existing Meta license header slot and a mapping layer normalizes them; an audit surface reports per-package license posture and loudly distinguishes copyleft/viral licenses and unresolved/unspecified entries, so GPL-family code cannot enter the tree unnoticed; vendored-package license indications additionally carry verification provenance - how the determination was made (automated scan of upstream license files, developer assertion, or upstream-supplied metadata) and by whom/what, with a date.
Acceptance: a mapping facility (punk module, name decided in the work) resolves friendly license names to SPDX ids - tolerant of case/spacing variants, covering at least every value currently present in Meta license headers, and returning a distinct "unresolved" result for unknown strings rather than guessing; an audit command (make.tcl subcommand or dev commandset command, decided in the work) enumerates packages under src/modules, src/vendormodules and src/vendorlib and reports each one's SPDX id, unresolved raw value, or unspecified; a documented copyleft policy list (GPL, AGPL and LGPL families at minimum) is flagged distinctly in audit output; module templates no longer emit the literal %license% placeholder; first-party <unspecified> headers are populated or the remainder listed here as pending with reasons; vendored packages under src/vendormodules and src/vendorlib record license verification provenance (method, verifier identity, date) in the recording mechanism chosen for vendored license capture, the audit surface reports it, and entries lacking provenance are reported distinctly rather than silently passing.
## Context
@ -31,5 +31,7 @@ it auditable per package.
- Related: G-026 (vendorupdate is the natural hook for capturing vendored license
provenance), G-060 (recorded GPL-safe posture), G-062 (project's own license id),
G-064 (lib.search as the interactive surface for these indications).
G-064 (lib.search as the interactive surface for these indications), G-065 (the
declarative vendor-sync is the capture point for external-upstream license
verification provenance), G-068 (moduledoc status as a candidate audit column).
- Archived-goal references in this file: G-062 achieved 2026-07-11 (goals/archive/G-062-project-license-file.md).

67
goals/G-065-declarative-vendoring.md

@ -0,0 +1,67 @@
# G-065 Declarative vendoring: toml-declared external packages with pinning, provenance and binary gating
Status: proposed
Scope: punkproject.toml or sibling vendor manifest (schema - settled in the work), src/modules/punk/mix/ (vendor-sync command surface), src/vendorlib/ + src/vendormodules/ + src/vfs/ (materialization targets), src/make.tcl (integration)
Goal: vendoring an external third-party package into punkshell (and, via the G-027 channel, derived projects) is driven by a toml declaration - upstream source, optional version/commit pin, trim rules - materialized and updated by one command that records retrieval provenance (upstream URL, commit/tag, retrieval date, license-verification provenance per G-063) and scans for executable binaries, refusing or warning unless punkproject.toml carries an explicit binaries-allowed override; manual drop-ins (a library folder or .tm file placed by hand, in vendor dirs or vfs) remain supported and are surfaced by audit as undeclared rather than rejected; basic vendoring requires no agent/LLM tooling.
Acceptance: a documented toml manifest schema exists and declaring a package plus running the sync command materializes it under src/vendorlib or src/vendormodules (and a vfs target where declared) on a system without agents; re-running after a pin change updates the materialized copy and an unpinned declaration records its resolved version at sync time; per-package provenance (upstream URL, commit/tag or version, retrieval date) is recorded and queryable; a declared package containing executable binaries is refused (warn-only selectable as a configured mode) unless the punkproject.toml override is present, exercised by a test or documented manual verification against a scratch package; a manually dropped-in library remains loadable and is reported as undeclared by the audit/status surface, not deleted; the ecosystem-metadata compatibility survey (teapot Meta headers, relevant TIPs, wiki.tcl-lang.org / tip.tcl.tk scan) is recorded in this file with the schema decisions it informed; src/vendorlib/tcl_oauth2_library is vendored via a declaration as the proving case; derived-project applicability (how the manifest and sync travel via G-027) has a recorded design decision - implemented or deferred with rationale.
## Context
Vendoring today is copy-shaped: clone the upstream (e.g. TEMP_REFERENCE/tcl_oauth2_library,
2026-07-12), trim by hand, drop the result into src/vendorlib. Nothing records where the
copy came from, what version/commit it represents, or what was trimmed - the same
provenance gap G-026 closes for local-project pulls, but for external upstreams, which
G-026 deliberately does not cover. The desirable end state resembles npm/mix/zig
dependency declaration: declare the package, optionally pin it, let tooling materialize
and update it. This is not an attempt at a general Tcl package manager, but where the
Tcl ecosystem has established metadata conventions (teapot Meta headers - already reused
by G-063 - and TIP-era distribution metadata) the schema should stay compatible; toml
remains the punkshell format (G-024 direction).
## Approach
- Manifest location: punkproject.toml section vs sibling file (e.g. vendor.toml) decided
in the work; either way parsed by the vendored tomlish (G-014/G-024 pattern).
- Declaration fields (candidate set): name, target area (vendorlib / vendormodules / vfs
path), upstream kind + URL (git / fossil / http archive), pin (tag / commit / version),
trim/keep rules (license, readme, examples kept by default), license expectation
(cross-checked by G-063 audit), binaries-allowed (default false).
- Sync command: punk::mix dev commandset or make.tcl subcommand (decided in the work);
agent-free by design - retrieval + trim + provenance recording only. Moduledoc
generation is explicitly out of scope here (G-068).
- Mixed mode is a contract, not a transition state: hand-dropped libraries stay legal;
the audit surface (shared with G-063's enumeration) classifies each vendored entry as
declared-in-sync / declared-stale / undeclared.
- Binary scan piggybacks the G-004 policy (root AGENTS.md binaries rule): scan the
materialized payload for executable binaries (shared libs, exes, zip-based .tm
embedding executables) before accepting it.
- Ecosystem survey is a bounded research step recorded here (Notes), informing field
names and metadata mapping - not an open-ended standards effort.
## Alternatives considered
- Extending G-026 to cover external upstreams - rejected: G-026 is a policy about pulls
from local sibling checkouts (dirty-checkout enforcement); external retrieval,
pinning and trim rules are a different mechanism sharing only the provenance
vocabulary.
- Requiring all vendoring to go through declarations (rejecting drop-ins) - rejected:
the user explicitly wants the mixed approach; drop-ins are surfaced, not blocked.
## Notes
- Related: G-004 (binary policy this enforces at vendor time), G-024 (toml direction),
G-026 (local-pull provenance sibling; include_modules.config -> toml note),
G-027 (derived-project travel), G-063 (license recording hook at vendor time),
G-066 (repackaging consumes declared packages), G-067 (artifact retrieval as an
alternative upstream kind), G-068 (moduledoc status tracked against the manifest).
- Proving case: tcl_oauth2_library. A hand-trimmed copy (keeping
LICENSE/license.terms/README/examples) was placed in src/vendorlib and load-tested
with the punk91 src shell on 2026-07-12, then deliberately removed before ever being
committed (2026-07-12) so the first real vendoring of it happens end-to-end through
the declaration - retrieval, trim rules, provenance, materialization - rather than as
a retrofit over an existing copy. The git clone in TEMP_REFERENCE/tcl_oauth2_library
remains the upstream stand-in; the hand trim is the reference expectation for the
declared trim rules' output.
- The adoption scenario (an existing manual drop-in surfaced as undeclared, then
brought under a declaration) still needs testing despite the removal - recreate it
at test time with a scratch drop-in copy.

40
goals/G-066-pkgindex-tm-repackaging.md

@ -0,0 +1,40 @@
# G-066 pkgIndex.tcl-to-.tm repackaging: lib.copyasmodule expansion with embedded metadata and distribution-unit tracking
Status: proposed
Scope: src/modules/punk/mix/commandset/loadedlib-999999.0a1.0.tm (lib.copyasmodule), src/modules/punk/mix/ (modpod/zipkit tooling as needed), src/tests/modules/punk/mix/ (converter testsuite)
Goal: third-party pkgIndex.tcl-based packages can be repackaged as single-file .tm modules (zip-based where the payload warrants it) that embed upstream documents (LICENSE, README) and a punkshell metadata datafile giving a consistent description of upstream name, version, license and distribution-unit membership - so packages split out of a multi-package upstream (tcllib-style) record that they shipped together at upstream version X and should be upgrade-checked as a unit - with the converter handling a substantially broader class of pkgIndex.tcl scripts than today's lib.copyasmodule and refusing clearly on scripts it cannot model rather than emitting a broken module.
Acceptance: the converter repackages a proving set of at least three packages - src/vendorlib/tcl_oauth2_library plus two tcllib packages, one of which has a non-trivial pkgIndex.tcl (multiple statements, computed version, or multi-file source list) - and each resulting .tm loads via package require on the primary target runtimes (Tcl 9 kit and 8.6, or a recorded limitation referencing the G-034 code-interp constraint); each repackaged module embeds the upstream LICENSE and a metadata datafile carrying upstream name, upstream version, license indication (G-063-resolvable) and distribution-unit fields; converting several packages from one upstream project in one run records a shared distribution-unit id and version queryable from the packaged artifacts (surface decided in the work); a pkgIndex.tcl construct outside the converter's modelled class produces an explicit refusal message naming the construct; converter behaviour is covered by a testsuite under src/tests/modules/punk/mix/.
## Context
punkshell prefers .tm modules; the wider Tcl ecosystem mostly ships pkgIndex.tcl
libraries and may continue to. Zip-based .tm modules get single-file distribution and
can carry license/readme/metadata inside the artifact - which also makes them the
natural payload for an artifact server (G-067). `dev lib.copyasmodule` already performs
a basic conversion and has worked on some tcllib modules, but pkgIndex.tcl scripts are
arbitrary Tcl and the current handling is narrow. Multi-package upstreams introduce the
unit problem: once tcllib (or similar) packages are split into individual .tm files,
nothing records that they came from one release and should be upgraded together - no
current goal touches this.
## Approach
- Grow lib.copyasmodule's modelled class of pkgIndex.tcl scripts incrementally
(ifneeded lines with source/load lists, simple computed versions), with an explicit
refusal path for everything else - correctness over coverage.
- Metadata datafile format: toml, schema shared with / derived from the G-065 manifest
vocabulary so vendored-in-place and repackaged artifacts describe themselves
consistently.
- Distribution-unit: unit id = upstream project identity (e.g. "tcllib"), unit version =
upstream release; recorded per artifact and aggregable ("what units are present, are
any mixed-version"). Upgrade-together enforcement is a consumer concern (G-065 sync /
G-067 retrieval) - this goal only guarantees the data exists.
- Loading on 8.6: zip-based .tm viability in the shell code interp is G-034's subject;
this goal records the limitation rather than solving mounting.
## Notes
- Related: G-034 (zip modpod mounting on 8.6), G-035 (mixed .tm/pkgIndex provision
characterization - what happens when both shapes of the same package are present),
G-063 (license fields in the datafile), G-065 (manifest schema sharing), G-067
(repackaged artifacts as the publish payload).

37
goals/G-067-module-artifact-channel.md

@ -0,0 +1,37 @@
# G-067 Module artifact channel: publish prepared .tm modules to and retrieve from configurable artifact servers
Status: proposed
Scope: src/make.tcl or punk::mix dev commandset (publish/retrieve surface - settled in the work), user-config (consent flag + server list, G-006 pattern), src/vendormodules/ + src/vendorlib/ (retrieval targets)
Goal: prepared .tm module artifacts (typically G-066 repackaged zipkits) can be published to a punkshell official artifact server and retrieved into punkshell or derived projects on declaration, following G-006's established patterns - consent gating by default, a default official source, user-configured alternative servers as replacement or addition, version pinning encoded in the artifact addressing, checksum verification where provided - sharing rather than duplicating G-006 machinery where it exists.
Acceptance: a retrieve operation fetches a named module artifact at a pinned version from a configured server into the project's vendor area and the module loads via package require; no retrieval happens without explicit consent (config flag or interactive prompt; non-interactive use without the flag fails with an actionable message, never downloads silently); multiple servers are configurable with a documented precedence and both replacement and additional semantics available; artifact checksums are verified when the server supplies them and a mismatch aborts the install; a publish path to the official server is documented and demonstrated (authentication mechanism decided in the work); retrieval works from a derived project, or derived-project support is recorded as deferred with rationale; the relationship to G-006's downloader (shared implementation vs parallel with recorded justification) is a recorded design decision.
## Context
G-006 establishes consent-gated artifact download for binary build artifacts (the
zig-built set). Modules are a distinct artifact class with the same retrieval-shaped
needs, and G-027's remote-pull question already names the G-006 channel as a candidate
transport - three goals converge on one artifact-retrieval substrate. Rather than
widening G-006's tightly binary-scoped acceptance, this goal gives modules their own
contract on the same design pattern. The publish side is what makes the official server
populatable: repackage (G-066), then push, so developers on other machines can declare
and pull (G-065) instead of re-vendoring by hand.
## Approach
- Addressing scheme encodes name + version (and unit id/version where the artifact
belongs to a G-066 distribution unit) so pinned retrieval is a URL construction, not
a server-side search.
- Consent and server-list configuration reuse the G-006 config surface (same keys or a
documented sibling namespace) - one consent story for all remote artifact fetching.
- Retrieval integrates as an upstream kind in the G-065 manifest ("from artifact server
X" alongside "from git repo Y"), so declared vendoring and artifact retrieval are one
developer experience.
- Official-server operation (hosting, retention, signing policy) is out of scope beyond
what publish/retrieve need to interoperate with it, mirroring G-006's stance on the
binary-artifacts repo.
## Notes
- Related: G-006 (pattern source and candidate shared implementation), G-027 (remote
infrastructure-pull transport candidate), G-065 (manifest integration), G-066
(artifact payloads and distribution-unit metadata).

41
goals/G-068-vendored-moduledoc-workflow.md

@ -0,0 +1,41 @@
# G-068 Agent-assisted moduledoc generation workflow for vendored third-party libraries
Status: proposed
Scope: goals/G-068-vendored-moduledoc-workflow.md (workflow doc), src/modules/punk/args/moduledoc/ (generated companion modules), src/tests/modules/punk/args/ (probe verification where feasible)
Goal: a vendored third-party library that neither ships nor references punk::args documentation can be given a punk::args::moduledoc companion through a documented, repeatable agent-assisted workflow - the G-055 pattern generalized beyond core.tcl-lang.org projects: upstream doc text (man pages, README, doctools) carried verbatim under the G-055 fidelity policy, synopses translated into punkshell's synopsis syntax, safe probe verification applied where feasible, upstream source/version provenance recorded with the definitions - run as a separate optional step after vendoring, so basic vendoring (G-065) never requires agent availability, with each vendored package's moduledoc status (present / absent / stale against the vendored version) trackable.
Acceptance: the workflow is documented in this file (inputs, fidelity and synopsis-translation policy by reference to G-055, probe-verification gate including the criteria for declaring probing infeasible for a command, provenance recording, and how the workflow consumes a vendored payload rather than a core source tree); a moduledoc companion produced through the workflow exists for at least one vendored library - src/vendorlib/tcl_oauth2_library as the proving case - loading alongside the untouched vendored source and surfacing in the help system; the vendoring path demonstrably completes without this step (a package vendored with no moduledoc loads and audits normally); moduledoc status per vendored package is queryable from a defined surface (G-065 manifest field or the G-063/G-064 audit surface - decided in the work) and distinguishes present, absent and stale-against-vendored-version.
## Context
External Tcl libraries will almost never carry punk::args documentation unless future
authors adopt it; giving them punkshell-quality help means authoring a moduledoc
companion. G-055 built the workflow shape for tclcore (verbatim fidelity, synopsis
translation, probe gates, provenance) and explicitly anticipated extension to other
projects while keeping them out of scope. Doc authoring needs judgement, so this is
likely always agent-assisted - which is exactly why it must be decoupled from basic
vendoring: a developer on a machine without agents vendors now and generates (or
receives) the moduledoc later. A published artifact (G-067) can carry its moduledoc
with it, so the generation cost is paid once per ecosystem, not once per developer.
## Approach
- Inputs per run: the vendored payload path + recorded upstream version (from G-065
provenance), plus whatever docs the upstream ships (man pages like oauth2.man,
README, doctools sources).
- Companion placement: a moduledoc module under src/modules/punk/args/moduledoc/
loading on 'package require <lib>' (tkcore pattern noted in G-055), or embedded in a
G-066 repackaged artifact - placement decision recorded in the work; the vendored
source itself is never modified (src/vendorlib contract).
- Probe verification: where commands are safe/pure enough, the G-055 real-vs-model
error/ok agreement gate applies; for network-touching or stateful commands (the
oauth2 case) the workflow documents the infeasibility criteria and falls back to
doc-fidelity review only.
- Staleness: moduledoc records the upstream version it was authored against; status
compares that to the currently vendored version.
## Notes
- Related: G-055 (workflow pattern source; its modelability-gap scan discipline applies
here too), G-063/G-064 (audit surfaces that could report moduledoc status), G-065
(manifest tracking, agent-free vendoring guarantee), G-066/G-067 (carrying moduledocs
inside repackaged/published artifacts).

50
goals/G-069-splitter-tclparser-lint.md

@ -0,0 +1,50 @@
# G-069 Dev-time lint: cross-check punk::args record splitting against tclparser where the binary is available
Status: proposed
Scope: lint surface (location settled in the work: punk::args dev helper, dev commandset, or scriptlib/developer script), src/modules/punk/args-999999.0a1.0.tm (split_definition_records as consumed, no new dependency), src/tests/modules/punk/args/ (capability-gated suite)
Goal: a dev-time lint cross-checks the record boundaries produced by punk::args' definition record splitter (private::split_definition_records) against the tclparser C library's 'parse command' tokenization of the same (ANSI-stripped, dialect-adjusted) text, so drift between the definition dialect and real Tcl parsing rules is caught as the dialect grows (-&, @normalize, future continuation/normalization directives) - without punk::args itself gaining any dependency on the tclparser binary.
Acceptance: a documented lint command (location decided in the work) accepts definition text and/or registered definition ids and reports record-boundary divergence between split_definition_records and tclparser 'parse command'; the comparison accounts for documented dialect features rather than flagging them (at minimum: ANSI escapes stripped before parse, -& record continuation pre-joined or classified as expected divergence); the lint is capability-gated - a clean, explicit skip/notice when the tclparser package is unavailable, never an error - and `package require punk::args` continues to succeed with no tclparser present; a run across all definitions registered in a punk9win kit shell (where the tclparser binary ships) yields zero unexplained divergences, with any real divergences found either fixed or recorded in this file; exercised by a capability-gated test or a documented manual run recorded here.
## Context
While implementing the -& record-continuation token (G-045, archived), tclparser's
'parse' command was considered and rejected as the splitter's parsing substrate: the
definition dialect legitimately deviates from Tcl (unbalanced-bracket ANSI escapes as
data inside quoted values, line-based base-indent semantics, the -& token), and a
binary dependency in punk::args - the module that must load everywhere, including
bootsupport contexts and plain tclsh - inverts the G-004 direction. The full analysis
is recorded in goals/archive/G-045-punkargs-authoring-ergonomics.md (Progress,
increment 3) and in the punk::args 0.7.0 changelog.
The rejected option still has value as a diagnostic: records are shaped like Tcl
commands (newline-delimited, quote/brace/backslash continuation), so where the binary
is available, parse-command boundaries are an independent oracle for the splitter.
As the dialect grows, an automated cross-check catches unintended divergence early -
the splitter's own testsuites (recordcontinuation.test, normalize.test, defquoting.test,
rendering.test) pin known behaviour but cannot flag novel drift against Tcl semantics.
## Approach
- Comparison pipeline: take the definition text as the splitter receives it, produce
splitter records; separately ansistrip the text, pre-join -& continuations (or
classify them), then iterate tclparser 'parse command' to produce boundary ranges;
compare record counts and boundary line positions, reporting divergences with
context.
- Corpus: registered definitions via punk::args introspection (update_definitions +
raw defs), plus ad-hoc text input for testing definition snippets during dialect
work.
- Home: this is developer tooling, not runtime behaviour - candidates are a
punk::args dev/diagnostic namespace proc (loaded lazily), a dev commandset command,
or a scriptlib/developer script following goals_lint.tcl precedent. Decide in the
work; the constraint is only that punk::args' runtime footprint is unchanged.
- The tclparser binary currently ships only in the punk9win kit (lib_tcl9); the lint
is expected to run there (or any environment with the package installed). If G-070
(pure-Tcl tclparser) lands, the gate widens automatically if the lint requires
'parser' via ordinary package resolution.
## Notes
- Related: G-045 (archived - origin of the rejected-then-repurposed idea and the
dialect features the comparison must understand), G-070 (pure-Tcl tclparser would
let the lint run without the binary), G-004 (why the runtime must stay
dependency-free).

56
goals/G-070-pure-tcl-tclparser.md

@ -0,0 +1,56 @@
# G-070 Pure-Tcl tclparser: parse-command API fallback with behavioural parity against the C library
Status: proposed
Scope: src/modules/punk/lib-999999.0a1.0.tm (tclparser_tcl stub + dispatch; new module if size warrants - decided in the work), src/tests/modules/punk/lib/ (parity + fallback suites), TEMP_REFERENCE/ (tclparser reference source, user-provided, read-only)
Goal: punk::lib's script-analysis machinery (tclword_to_scriptlist and the dependent analysis paths) runs on runtimes without the tclparser C binary, via a pure-Tcl implementation of the tclparser 'parse' command API covering at least the subcommands and token shapes punk::lib consumes - with behavioural parity verified against the C library rather than assumed, and the C parser still preferred where present (performance).
Acceptance: the punk::lib tclparser_tcl error stub is replaced by a working pure-Tcl implementation of the 'parse' subcommands punk::lib currently uses ('parse command' at minimum; the full covered set enumerated in this file during the work), returning the same parse-tree shapes (ranges, token types, expansion handling) the C library returns for those calls; a parity testsuite compares pure-Tcl output against the C tclparser across a recorded probe corpus (representative punkshell module scripts plus edge cases: {*} expansion, comments, backslash-newline continuation, nested command substitution, braces/quotes in words) - parity tests capability-gated on the binary, pure-Tcl-only tests running everywhere; a documented punk::lib analysis entry point (e.g. tclword_to_scriptlist) demonstrably works under a plain tclsh with no parser binary on the package path; dispatch prefers the C parser when available with the fallback engaging automatically otherwise; the reference source's location under TEMP_REFERENCE and its provenance (origin URL, version/checkin) are recorded in this file.
## Context
punk::lib uses the tclparser C library's 'parse command' for script analysis (word
-> scriptlist conversion, argument/expansion classification - the G-019
dependency-scan direction). The binary ships only in the punk9win kit's lib_tcl9,
so the analysis paths are unavailable on plain tclsh, other kits and other
platforms - and lib-999999.0a1.0.tm carries an explicit placeholder:
tclparser_tcl (currently an error stub telling the user to install the C library)
plus a recorded deliberation at tclscript_to_toplevelinfo noting "we don't have a
pure-tcl implementation of 'parse' available as a fallback". A tested pure-Tcl
implementation removes the binary constraint from every current and future consumer
and aligns with the G-004 no-committed-binaries direction. It also widens where the
G-069 splitter cross-check lint can run.
The user will place the latest obtainable tclparser source (C implementation and
any docs/tests) under TEMP_REFERENCE as the read-only behavioural reference
(candidate upstreams: the aspect fossil fork at chiselapp and the ActiveState
teapot tree, both already noted in the tclparser_tcl stub comments; punkshell also
carries a moduledoc for the parse command at
src/modules/punk/args/moduledoc/parser-999999.0a1.0.tm).
## Approach
- Scope the API by consumption, not by the full tclparser surface: enumerate the
subcommands and result fields punk::lib actually reads (parse command ranges,
token lists, restRange handling, simple-expansion trees), implement those first,
and record the covered/uncovered set here. Other subcommands (expr, varname,
list) follow only if a consumer needs them.
- Parity-first workflow (G-055 spirit): build the probe corpus and the
compare-against-C harness before/alongside the implementation, so every
implemented construct lands with a parity pin. Corpus draws on real punkshell
module sources (rich in expansions, multi-line commands and comments) plus
targeted edge cases.
- Dispatch: a thin front (in punk::lib or the new module) that package-requires the
C parser and falls back to the pure-Tcl engine; consumers call one entry point.
Performance note recorded rather than chased - the C library stays preferred
where installed.
- Placement: the stub lives in punk::lib today; a parser is a sizeable,
separately-testable unit, so a dedicated module (name TBD, e.g. under punk::)
with punk::lib delegating is the likely shape - decided in the work.
## Notes
- Related: G-019 (dependency-scan module trimming - the main analysis consumer),
G-004 (binary-free direction), G-069 (lint gains a binary-free substrate),
G-055 (parity-verification workflow precedent).
- The tclparser_tcl stub's partial char-loop sketch (in_dq/in_cb/escape state) is a
starting point only; the reference source's Tcl_ParseCommand semantics are the
contract.

68
goals/G-072-punkargs-compound-clause-types.md

@ -0,0 +1,68 @@
# G-072 punk::args compound clause types: named alternates with per-element typing and per-alternate arity (try-class handlers)
Status: proposed
Scope: src/modules/punk/args-999999.0a1.0.tm (type-expression parsing, clause allocation, synopsis/help renderers), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (::try as proving consumer; ::if/::switch as touched), src/tests/modules/punk/args/testsuites/args/
Goal: heterogeneous repeating clauses - try's on/trap handlers are the motivating case - can be modelled with per-alternate element types, choices and arity: 'try {} on bogus {e} {s}' is rejected with the code choice set displayed, alternates may differ in element count, mixed-order interleaving keeps parsing, and synopsis/help render each alternate's shape distinctly instead of a conflated element - with the definition mechanism (named compound types, composition operators, cross-package scoping) decided and recorded, informed among others by set-theoretic type composition.
Acceptance: a definition mechanism (decided in the work, starting from the sketches embedded in the ::try moduledoc notes: named compound types with arity, @typeimport, composition operators) lets the ::try handler be modelled so that parse rejects 'on bogus ...' displaying the on-code choices (ok/error/return/break/continue or integer) and types the trap pattern as a list, while mixed-order on/trap interleaving and finally positioning keep parsing (the 2026-07-12 probe matrix stays green); alternates of differing arity are expressible and exercised by at least one fixture (per-alternate arity derived or declared - decision recorded); synopsis and help output render each alternate's element shape distinctly (the deliberately-unpiped oncode_or_trappattern -typesynopsis workaround retired); type-name scoping across documentation packages has a recorded decision (namespacing/@typeimport mechanism, or rejection with rationale); the ::try moduledoc adopts the mechanism with real-vs-model parity probes added (::if/::switch adopt it only where it improves their models); definitions not using compound types behave unchanged (full punk::args suite passes, none weakened).
## Context
The ::try moduledoc models handlers as a single 4-element clause
{literal(on)|literal(trap) string list string} -multiple 1. Probing (2026-07-12,
G-041 prework) confirmed this parses everything structural correctly - mixed
on/trap order, finally positioning - but over-accepts semantically: the second
element cannot be typed per-alternate, so 'on bogus {e} {s}' passes where Tcl
errors with the valid completion codes. The moduledoc carries extensive embedded
design notes at ::try recording the difficulty and sketching mechanisms (named
compound types with arity indicators such as <on_handler:4>|<other_handler:3>,
per-definition vs global type registries, @typeimport for cross-package reuse,
RPN composition like {stringstartswith a stringendswith z AND int OR}).
Related over-acceptance findings from the same probes are listed in
goals/G-055-tclcore-regen-workflow.md Notes (reserved-word clause allocation,
'-' fallthrough position constraints) - candidates to fall out of the same
mechanism or be recorded as out of scope here.
## Set-theoretic types input (user direction 2026-07-12)
Investigate whether gradual set-theoretic types as adopted by Elixir
(https://elixir.hexdocs.pm/main/gradual-set-theoretic-types.html) can inform the
mechanism. Brief examination at drafting time:
- Elixir treats types as SETS of values composed with union (or), intersection
(and) and negation (not); literal values are singleton types; dynamic() effects
gradual typing; multi-clause functions are intersections of arrow types.
- Mapping to punk::args: -type's existing | is already set union and
literal(word) is already a singleton type - the existing vocabulary is a
fragment of a set-theoretic algebra, so extension is consistent rather than a
redesign. punk::args' 'any' plays the dynamic() role.
- Negation would directly express the reserved-word gap recorded in G-055's
notes: an if else-script is "script and not literal(elseif)" - i.e. deny-lists
for clause members become type negation instead of a bespoke feature.
- Intersection covers the composite constraints the ::try notes sketched as RPN:
{stringstartswith a stringendswith z AND int OR} reads as
((startswith(a) and endswith(z)) or int) - infix/functional composition on set
semantics can replace the RPN idea.
- Named compound types then become type ALIASES over such compositions, and a
heterogeneous clause is a UNION of tuple-like clause shapes whose arity derives
from each shape's length - answering the notes' open question about declaring
arity for alternates of differing length (arity is per-alternate and computed,
not separately declared).
- A full algebra is likely overkill for the immediate try case; the design should
pick operator spellings consistent with set-theoretic composition so later
extension (negation for reserved words, intersection for composite string
constraints) slots in without breaking earlier definitions.
## Notes
- Related: G-041 (form selection and clause allocation interact; its candidacy
machinery should not need changes from this goal's types), G-071 (allocation
correctness for optional elements, achieved 2026-07-12 - see
goals/archive/G-071-punkargs-optional-allocation.md; its choiceword_match
allocation screen and allocation.test fixtures are the base this goal's
alternates ride on), G-055 (modelability findings list; its parity workflow
verifies whatever this goal makes expressible), G-053 (occurrence arity -
adjacent clause machinery).
- Display cost matters: the ::try notes warn bracketed alternate forms "get
unwieldy in synopsis listings" - synopsis rendering of compound types is part
of the mechanism's acceptance, not an afterthought.
- Archived-goal references in this file: G-041 achieved 2026-07-13 (goals/archive/G-041-punkargs-form-matching.md).

64
goals/G-073-punkargs-unavailable-choices.md

@ -0,0 +1,64 @@
# G-073 punk::args unavailable choices: displayed with notes and prefix-reserving, but rejected with a tailored message
Status: proposed
Scope: src/modules/punk/args-999999.0a1.0.tm (spec key, choiceword_match pool, choices rendering in arg_error table+string renderers, validation message), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm ('string is' forward-class adoption + per-class virtual id), src/tests/modules/punk/args/testsuites/args/ (new suite + tclcoreparity.test exemption)
Goal: an argument definition can declare choices that are recognised but not available in the current runtime (new key, candidate spelling -choiceunavailable <list>): they display among the choices with their ordinary -choicelabels notes (visually distinguished), participate in prefix disambiguation like -choiceprefixreservelist phantoms, and are rejected at parse with a tailored message identifying the unavailability rather than the generic listed-values error - adopted by the tclcore 'string is' model so that Tcl 8.6 shows the dict class annotated "(class not present in Tcl 8.6)", rejects 'string is dict' informatively, treats 'string is di' as ambiguous (deliberately stricter than real 8.6, preparing 8.6 users for 9.x where dict/digit/double collide), and documents 'i string is dict' via a per-class virtual id generated from the static description.
Acceptance: a definition using the new key renders unavailable entries distinguishably among the choices (mechanism decided in the work: dedicated group heading, dim/warn styling, or marker) with their -choicelabels notes, in both the table and string renderers, and synopsis choice-literal display excludes them; parse/parse_status reject an unavailable choice word - exact, or a unique prefix resolving only to it - with a message identifying it as recognised-but-unavailable rather than the generic choice error; unavailable entries participate in prefix disambiguation via choiceword_match (a prefix shared between an available and an unavailable choice is rejected as ambiguous) and are honoured identically by the punk::ns doc walk and the G-071 allocation screen (parity free via the shared resolver - no second matching rule); value-in-effect/goodarg highlighting never marks an unavailable entry; the tclcore 'string is' definition adopts the key on runtimes lacking known forward classes (dict on 8.6; the forward list is explicitly curated in the definition - unicode deliberately excluded as never-released), with a per-class virtual id generated from the static description so 'i string is dict' documents the class with its note on 8.6; the deliberate strictness divergence (real 8.6 accepts 'string is di' as digit; the model rejects it as ambiguous) is recorded in tclcoreparity.test as a user-sanctioned exemption class rather than silently special-cased, with full-word behaviour staying parity-true on both versions; definitions without the key behave unchanged (full punk::args and punk::ns suites pass).
## Context
User requirement 2026-07-12: forward compatibility for the 8.6-vs-9 'string is
dict' difference. The G-054 harvest (archived) makes the class choices accurate
per-runtime, so 8.6 neither displays nor mentions dict - but an 8.6 programmer
who habituates to 'string is di' (unique prefix of digit on 8.6) writes code that
breaks on 9.x where dict makes 'di' ambiguous. The requirement: 8.6 should SEE
dict (with an unavailability note - the description text with its "(class not
present in Tcl 8.6)" annotation already exists in the static
string_is_class_descriptions dict, it just never renders on 8.6), have dict
participate in prefix disambiguation (so 'di' is ambiguous on 8.6 too), and still
reject dict as an actual argument (real 8.6 rejects it).
Capability assessment (2026-07-12/13): the display layer is ready - choicelabels
notes, choicegroups headings and choiceinfo markers all exist; the gap is purely
that choices-cell membership implies parse acceptance under -choicerestricted 1.
The trie mechanics exist as -choiceprefixreservelist (phantom prefix-calculation
members, G-040), but reservelist phantoms deliberately do not display (the
punk::help c/to/tc phantoms must stay invisible) and reject with the generic
choice error. Hence a first-class concept: ~80% parse semantics, ~20% display,
riding the existing rendering pipeline.
## Approach
- New per-argument key (spelling decided in the work; candidate -choiceunavailable
<list>). Entries must not also appear in -choices/-choicegroups (definition
error). Notes come from ordinary -choicelabels entries keyed by the unavailable
word.
- choiceword_match: unavailable entries join the prefix pool like reservelist
members, but a match landing on one returns a distinct indication so validation
can emit the tailored message ("'dict' is a recognised choice but not available
in this runtime - see its note") instead of the generic listed-values error.
The shared-resolver principle keeps parse, parse_status, the G-071 allocation
screen and the (G-051) doc walk automatically consistent.
- Display: render unavailable entries among the choices with their notes,
visually distinguished (group heading vs dim/warn styling vs marker - decided
in the work with display-cost in mind); excluded from value-in-effect and
goodarg marking; excluded from synopsis choice-literal alternates.
- tclcore adoption: the 'string is' define-time harvest computes
unavailable = curated_forward_list - live_classes (forward list: dict;
unicode excluded). The per-class virtual id loop extends to unavailable
classes using the static description so 'i string is dict' works as
documentation on 8.6.
- Parity: tclcoreparity.test gains a recorded exemption class for
prefix-collision strictness (model stricter than real 8.6 for 'di'-shaped
prefixes by user direction); full-word probes stay strict parity.
## Notes
- Related: G-040 (choiceword_match single-resolver principle and the reservelist
precedent), G-051 (doc walk consumes the resolver - achieved 2026-07-13),
G-071 (allocation screen consumes the resolver - achieved 2026-07-12), G-054
archived (harvest + the parity pins this goal amends), G-055 (version-adaptive
definition technique; its workflow guidance gains the forward-class pattern
once proven here).
- The reject-message wording should steer the user: name the note/annotation and
the version boundary, not just "invalid".

86
goals/G-074-punkargs-multiform-ambiguity-lint.md

@ -0,0 +1,86 @@
# G-074 punk::args multiform ambiguity analysis: on-demand form-overlap detection with sanctioned-overlap annotation
Status: proposed
Scope: src/modules/punk/args-999999.0a1.0.tm (analysis command, @form sanction key as decided in the work), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (::after/::lseq as proving consumers, sanction adoption), src/tests/modules/punk/args/testsuites/args/ (new suite)
Goal: an on-demand analysis command (candidate spelling punk::args::formcheck <id>) reports, for a multiform definition, the form pairs an argument list could cleanly match simultaneously - a conservative static pass over the forms' leading slots and arity windows that flags discriminator-vs-permissive-type alignments (the 'lseq 1 count 5' class, where a literal/choice discriminator in one form aligns with a non-validating type like expr/any/string in another) and discriminator-free overlaps (the 'after cancel id|script' class) - with a definition-level annotation (spelling decided in the work, e.g @form key) that sanctions known/documented overlaps so they report as acknowledged rather than as findings, keeping the unsanctioned report actionable; the define hot path is untouched (analysis runs only on demand - interactive, test-time, and as a G-055 verification-gate step for regenerated multiform commands).
Acceptance: the analysis command reports the two known real cases from the G-041 closeout - ::lseq range/start_count overlap via the expr-typed end slot (unsanctioned finding: type-weakness class) and ::after cancelid/cancelscript (documented-overlap class) - and reports nothing for multiform definitions whose forms are fully discriminated (the parse withid/withdef pair, the forms.test afterish fixture); classifications distinguish at minimum type-weakness overlap (a discriminator aligned with a permissive type) from structural overlap (no discriminating slot exists); the sanction annotation silences (or downgrades to acknowledged) a listed form pair without affecting parse behaviour, is rejected at definition resolve when it names unknown forms, and is adopted for the after cancel pair in the tclcore moduledoc; the analysis is conservative in the documented direction (may miss deep ambiguities, must not false-alarm on discriminated forms - the miss/report boundary is documented with the slot model); running it performs no parse of user-supplied words and adds no work to define/resolve for definitions that never call it; a new testsuite covers the finding classes, the sanction, the unknown-form rejection and the no-finding cases; full punk::args suite passes.
## Context
User direction 2026-07-13 (during the G-041 closeout): "The encountered ambiguities
suggest weakness in the doc-blocked types - and perhaps requirement to evaluate for
ambiguities at define time to reject / warn"; the lint-time (on-demand) shape of this
goal was recommended and user-selected ("draft lint-time analysis").
The two ambiguities G-041's candidacy machinery surfaced on the tclcore models:
- 'lseq 1 count 5' -> multipleformmatches range+start_count. Root cause is a
TYPE-WEAKNESS overlap: start_count's literalprefix(count) discriminator aligns
positionally with range's end slot typed number|expr, and -type expr performs no
validation, so the word 'count' satisfies both forms. Real lseq resolves it
semantically (start_count). The G-055 detail file carries the operand-typing
probes (2026-07-13): indexexpression is a poor lexical fit (under-accepts
1e2/2*3/sqrt(9)/bracketed exprs, over-accepts end/end-1); the precise
discriminator is expr SYNTAX validation, needing a non-evaluating parser
(G-069/G-070 territory); TIP 746 makes the operands number-only on 9.1+.
- 'after cancel someid' -> multipleformmatches cancelid+cancelscript. A GENUINE
documented overlap: 'after cancel id' vs 'after cancel script ?script?...' are
textually indistinguishable for one trailing word - real Tcl disambiguates by
the id's runtime shape. Correct in a doc-faithful model, but indistinguishable
from an authoring mistake without a sanction mechanism.
Full ambiguity detection is language intersection between forms - not decidable in
general (types like regex-validated strings, unbounded -multiple tails) - but the
encountered classes are cheap to detect statically: walk the aligned leading slots
of each form pair over their overlapping total-arity windows and test whether every
slot pair is co-satisfiable, flagging pairs where a discriminator slot
(literal/literalprefix/restricted-choices) faces a permissive type (expr, any,
string, none-validating) and pairs with no discriminating slot at all.
## Approach
- Pairwise form analysis over the resolved spec (FORMS/ARG_INFO - no re-parse of
definitions): compute each form's value-arity window (VAL_MIN/VAL_MAX plus
optional-member clause arithmetic as in the allocator); for pairs with
intersecting windows, align leading slots and classify each aligned pair as
disjoint (two different literals; literal vs type that rejects it),
co-satisfiable-weak (discriminator vs permissive type), or co-satisfiable-equal
(same/overlapping permissive types). A pair with no disjoint slot in any aligned
position within the shared window is a finding: type-weakness if some slot is
discriminator-vs-permissive, structural otherwise.
- Choice/literal words screened via choiceword_match (the shared G-040 resolver) so
the static disjointness test cannot diverge from parse acceptance - the same
principle that kept G-071's allocation screen and G-051's doc walk honest.
- Options: forms whose option sets differ do not discriminate positionally (options
are order-free); first pass ignores opts for alignment (documented as part of the
conservative boundary) - the arity windows still gate.
- Sanction: an @form-level key (spelling decided in the work; candidates
-overlapallowed <formname list> on either member, or a definition-level
directive) recorded in the spec, validated at resolve (unknown form names error),
consumed only by the analysis (parse behaviour unchanged - G-041's
multipleformmatches error still fires; the sanction documents intent, it does
not pick a winner).
- Report shape: machine-parsable dict (per finding: forms, class, witness slot(s),
an example witness arglist shape where derivable, sanctioned flag) plus a human
summary - the dict is what the G-055 verification gate and test pins consume.
- Consumers: G-055's regeneration workflow gains a gate step "run formcheck on
every regenerated multiform command; new unsanctioned findings block acceptance";
interactive use for definition authors debugging multipleformmatches errors.
## Notes
- Related: G-041 achieved 2026-07-13 (goals/archive/G-041-punkargs-form-matching.md)
- its candidacy machinery is the runtime oracle for a GIVEN arglist; this goal is
the static answer for ALL arglists, and its Progress section records both
motivating ambiguities. G-055 (verification-gate consumer; operand-typing probes
in its notes), G-072 (compound clause types change the slot model - if active
concurrently, coordinate the alignment walk), G-069/G-070 (a future expr
syntax-validating type would convert the lseq finding from type-weakness to
resolved - the analysis should then report the pair as discriminated).
- Define-time always-on checking was considered and rejected with the user's
concurrence: define/resolve is a hot path and most definitions are single-form;
on-demand covers the authoring and regeneration moments where findings are
actionable. A future opt-in (@dev-time strict mode) could call the same analysis.
- The 'after cancel' sanction is also the display question's anchor: a sanctioned
overlap is why the G-041 doc surface marks BOTH forms and shows the ambiguity
message - the sanction must not suppress that runtime honesty.

67
goals/G-075-punkargs-package-ids.md

@ -0,0 +1,67 @@
# G-075 punk::args (package) ids: working lookup and a user-facing package documentation surface
Status: proposed
Scope: src/modules/punk/args-999999.0a1.0.tm (id lookup/update_definitions prefix handling, usage/arg_error rendering of package-level ids), src/modules/punk/ns-999999.0a1.0.tm (cmdhelp/help-system surface), src/modules/punk/mix/#modpod-templates-999999.0a1.0/templates/modules/template_module-0.0.4.tm (template block as touched/verified), src/tests/modules/punk/args/testsuites/args/ + src/tests/modules/punk/ns/testsuites/ns/ (new coverage)
Goal: the "(package)<pkgname>" ids that the module template (dev module.new) and the punk::args::moduledoc::* packages already declare become functional and user-visible - the id lookup path handles the (package) prefix (today punk::args::id_exists returns 0 for them and update_definitions emits an unqualified-ns warning, because only the (autodef) prefix is stripped when deriving a namespace from an id), a package-level id renders its @package -name/-help as a package documentation page through the usage machinery, and the interactive help surface reaches it ('i <pkgname>' presenting the package doc when <pkgname> resolves to a documented package rather than a command - exact surface decided in the work), making @package -help the natural home for per-package conventions and usage notes (e.g a moduledoc's harvest/provenance conventions).
Acceptance: punk::args::id_exists returns true for a registered "(package)<pkgname>" id after its namespace's scan (the update_definitions "received unqualified ns" warning no longer fires for (package)-prefixed lookups); punk::args::usage renders a (package) id via the @package directive's fields (render shape decided in the work - it has no arguments table unless the definition adds directives; at minimum -name and -help present cleanly in table and string renderers); the interactive surface is decided, documented and implemented ('i <pkgname>' or documented equivalent) with behaviour defined for the name-collision case (a namespace that is both a documented package and an ensemble command); the four moduledoc (package) ids (iocp, parser, tkcore, tzint) and at least one template-created module's id render through the new surface; the template's (package) block is verified as-written (or minimally corrected in step, with the template version bumped per its conventions); discoverability exists in some listed form (e.g ids matching (package)* via an existing query/listing command - decided in the work); new tests cover lookup, rendering and the interactive surface; full punk::args and punk::ns suites pass.
## Context
User context 2026-07-13: the (package) ids were intended somewhat as the surface for
package-level documentation/guidance but "aren't yet surfaced to the user"; they come
from the module template, so most modules created with 'dev module.new' carry one.
Probed state (2026-07-13, tclsh 9.0.3): 45 modules under src/modules declare
@id -id "(package)<pkgname>" blocks with an @package -name/-help directive
(template_module-0.0.4.tm carries the canonical block). They are currently inert:
- punk::args::id_exists "(package)punk::args::moduledoc::parser" returns 0 even with
the package loaded and registered.
- The lookup emits: warning: update_definitions received unqualified ns:
(package)punk::args::moduledoc - real_id derives a namespace from the id's
qualifiers, and only the (autodef) prefix is special-cased/stripped; the (package)
prefix flows into the namespace name.
- The resolver itself understands the @package directive (the spec dict carries a
package_info key alongside cmd_info/doc_info) - the gap is lookup and surfacing,
not definition storage.
The immediate motivation is the guidance-space discussion (recorded in G-055 and the
punk::args 0.11.1 define doc): system-wide authoring mechanics now live in the
punk::args::define help, and per-package conventions (harvests, provenance, version
adaptivity of a moduledoc) belong with the package - @package -help is the declared
place, once reachable.
## Approach
- Lookup: extend the prefix handling where namespaces are derived from ids
(real_id/update_definitions) so "(package)" is recognised like "(autodef)" -
the namespace for scan purposes is the id minus the prefix. Verify tail-colon
handling ((package) ids name namespaces, not commands - the id has no command
tail).
- Rendering: punk::args::usage on a (package) id - drive the existing arg_error
machinery with package_info (title/name/help; no Arg table rows unless the
definition declares arguments). Check interaction with the synopsis section
(suppress or render a package-appropriate header).
- Interactive surface: punk::ns::cmdhelp fallback - when the query word is not a
command but matches a documented package/namespace, present the package page.
Collision rule (documented): a real command (e.g an ensemble named for the
namespace) wins; the package doc is reachable explicitly (spelling decided -
e.g 'i (package)foo::bar' at minimum).
- Discoverability: a listing of documented packages (existing id query/listing
machinery filtered on the prefix, or a punk::args::status extension).
- Template: verify the template block round-trips through the fixed path; the
moduledoc (package) blocks likewise (tclcore notably has none - adding one is
a natural but optional step recorded either way).
## Notes
- Related: G-055 (per-package conventions/provenance belong in @package -help once
surfaced; its workflow doc should point there when this lands), G-042 (subshell
help topics - adjacent help-surface work; keep the surfaces consistent),
G-017/G-068 (package-level documentation consumers).
- The punk::args::define help (0.11.1) documents registration styles and defspace
rules system-wide; this goal's surface is where the per-package layer of that
guidance becomes reachable.
- 45 declaring modules found 2026-07-13 via grep for '"(package)' under src/modules
(plus decktemplates vendor copies); count will drift - the acceptance names the
four moduledocs + one template-created module as the proving set, not all 45.

157
goals/archive/G-039-orphan-console-spin.md

@ -0,0 +1,157 @@
# G-039 Investigate the orphaned-shell one-core spin on a dead console
Status: achieved 2026-07-12
Scope: src/modules/punk/repl-999999.0a1.0.tm (console reader/event loop and EOF/error paths), src/modules/punk/console-999999.0a1.0.tm; investigation-first
Goal: the observed failure mode - an interactive punk902z left running after its hosting terminal/console went away spins roughly a full core indefinitely (observed 2026-07-08: a 37-minute orphan with a single hard-looping thread) - is reliably reproduced and root-caused, then fixed or mitigated so a shell whose console dies exits or reaches zero-CPU idle cleanly.
Acceptance: a documented procedure reproduces the spin on the current kit (e.g. launch an interactive shell in a terminal, then kill/close the hosting terminal or conhost), or the investigation records the attempts made and what evidence would reopen it; the spinning code path is identified (prime suspect: a console read/event loop treating a dead console's immediate EOF/error as retryable without backoff or termination - adjacent to the console-EOF restart path G-038 takes ownership of); after fix/mitigation, the same procedure shows the orphaned process exiting or settling at effectively zero CPU within a short grace period, with live-console interactive behaviour unchanged; the wedge-scoring hazard note (orphans polluting process-liveness checks in test harnesses) is updated to match the outcome.
## Context
Observation (2026-07-08, during the G-036 investigation): a punk902z process from an earlier
interactive session (started ~37 minutes prior; its hosting terminal presumed closed) was
found consuming CPU continuously - ~1550 CPU-seconds accumulating at roughly +0.5-1
core-seconds per wall second, with exactly ONE thread in Running state (1546s of the total)
and every other thread idle in normal waits. The process had to be killed manually. It also
polluted the wedge-scoring checks of the day (process-liveness was being used as a hang
signal), which is how it was noticed.
Not diagnosed at the time (killed to unblock the G-036 work); no dump was taken. The
suspicion: when the hosting console goes away, a console channel read/event path returns
immediately (EOF or error) and the surrounding loop retries without backoff or a
give-up-and-exit decision. Candidate sites: the repl reader loop, the EOF branch behaviour
when reopen/restart fails or loops (note tcl_interactive would be true for a real console,
enabling the `after 1 reopen_stdin` path - see the race notes in the G-038 detail file), or
punk::console query/read helpers.
## Approach
1. Reproduce: launch an interactive shell in a disposable terminal and kill the host
(close the tab/window; `Stop-Process` on the terminal; for classic conhost, killing
conhost.exe; also try Windows Terminal vs conhost - behaviour may differ). Watch the
orphan's CPU and thread states (`(Get-Process punk902z).Threads` sorted by
TotalProcessorTime). Try both idle-at-prompt and mid-command states at kill time.
2. If reproduced: procdump + WinDbgX scripted stack capture of the spinning thread (the
G-036 detail file documents the working tooling pipeline and pitfalls - WinDbgX process
name is DbgX.Shell, cdb is ACL-buried, `-z/-p -c -logo` scripting works). With the spin
thread's stack, map to the retry loop and decide fix: treat dead-console EOF/error as
terminal (exit per PUNK_PIPE_EOF-like policy), or add backoff + detection.
3. Coordinate with G-038: its caller-driven restart owns the console-EOF path; the dead
console case is the failure branch of the same decision point (reopen CONIN$ fails or
the console is gone entirely) and should be designed together.
## Notes
- Related: G-038 (console-EOF restart ownership, reopen_stdin race notes), G-036 (dump/
debugging tooling pipeline), tcl86-console-parked-read prior art (different mechanism,
same neighbourhood).
- Harness hygiene (resolved 2026-07-12): kits were rebuilt with punk::repl 0.5.0 the same
day (plain-kit kill test re-verified: exit within 2.0s), so the stray-orphan precaution
applies only when deliberately testing pre-0.5.0 kits.
- Upstream: the root cause is a Tcl 9 core defect pair in win/tclWinConsole.c (see
Progress below): (a) ConsoleEventProc drops the error/EOF notification that
ConsoleSetupProc/ConsoleCheckProc generate for lastError != 0 (only ring-buffer data
produces a Tcl_NotifyChannel), so a script can never see a dead console via fileevent;
(b) ConsoleReaderThread's persistent-error branch (inputLen==0 && lastError!=0) wakes
CVs + NudgeWatchers and continues with no wait, busy-looping one core (and via the
wakeups a second) until the channel is closed. Both defects verified against a plain
stock tclsh 9.0.3 (repro script with a stdin fileevent + vwait: ~1.85 cores after
conhost kill, readable handler never fires) and the code is unchanged in the 9.1b1
reference sources. A ready-to-file ticket draft with the standalone repro lives at
`TEMP_REFERENCE/tcl9-dead-console-spin-TICKET-DRAFT.md`. A kit-side Tcl source patch
is an alternative once G-005/G-006 build infrastructure exists; the script-level
watchdog stays valid regardless.
- Archived-goal references in this file: G-036 achieved 2026-07-08 (goals/archive/G-036-tcl9-udp-console-worker-wedge.md).
## Progress
### 2026-07-12 reproduced, root-caused, fixed in source (punk::repl 0.5.0)
Reproduction procedure (reliable, first-try, current punk902z kit 2026-07-11 build):
1. Launch the shell interactively under a dedicated classic conhost (isolates the kill
from the user's terminal):
`Start-Process -WindowStyle Hidden conhost.exe -ArgumentList 'C:\repo\jn\shellspy\bin\punk902z.exe'`
(optionally via cmd.exe with `> out.txt 2> err.txt` redirection to capture the trail;
`punk902z src` for dev modules).
2. Confirm the tree (`punk902z` is a child of the new conhost) and let it settle at the
idle prompt (CPU delta 0 over several seconds).
3. `Stop-Process -Force` the conhost pid. punk902z survives and immediately spins:
~1.7-1.9 cores, matching the 2026-07-08 observation (that one showed ~1 core; both
hot threads here are part of the same mechanism). Both idle-at-prompt kill state and
the redirected-stdio variant reproduce.
Spin mechanism (dump: procdump64 -ma + `WinDbgX -z <dmp> -c "~*k 30; .logclose; q" -logo`,
per the G-036 tooling notes; mapped against TEMP_REFERENCE/tcl9/win/tclWinConsole.c):
- Hot thread 1 = the Tcl console reader thread (stack: thread start -> ConsoleReaderThread
-> SetEvent storm). After the console dies, ConsoleDataAvailable returns -1
(PeekConsoleInputW fails) which the caller treats as truthy, ReadConsoleChars fails
(observed error: broken pipe - ERROR_BROKEN_PIPE, not ERROR_INVALID_HANDLE) and
handleInfoPtr->lastError is set. From then on the reader thread's top-of-loop branch
(inputLen>0 || lastError!=0) runs every iteration - WakeAllConditionVariable +
NudgeWatchers(SetEvent/Tcl_ThreadAlert) + continue - with no sleep on that path, until
channel close drops numRefs to 1. lastError is never cleared because the script never
reads again (see next point).
- Hot thread 2 = the repl/main interp thread: each nudge alerts its notifier;
ConsoleSetupProc counts lastError as "readable" and sets max block time 0, and
ConsoleCheckProc queues a console event for it - but ConsoleEventProc only calls
Tcl_NotifyChannel when the ring buffer has DATA and ignores lastError entirely, so the
queued event is discarded and the armed readable fileevent (repl_handler) NEVER fires.
The event loop spins at zero block time queueing and discarding events.
- Script level is therefore completely blind: instrumented src-mode run confirmed
repl_handler never runs after console death (no entry, no EOF branch, no reopen_stdin,
no bgerror, vwait never returns), so nothing ever closes the channel and both spins are
permanent. The reopen_stdin restart loop suspected in the original goal statement is NOT
the mechanism - it never gets the chance to run.
Fix (src/modules/punk/repl-999999.0a1.0.tm, punk::repl 0.5.0):
- New `repl::console_watchdog` self-rescheduling liveness poll (default 5000ms,
`repl::console_watchdog_ms`), armed by repl::start before its vwait, only when platform
is windows AND the repl serves the process-default console AND the input channel's
`chan configure` dict has `-inputmode` (i.e. a tcl9 console channel; piped stdin,
foreign consoles and tcl 8.6 console channels never arm it). The probe is a read-only
`chan configure $inchan -inputmode` (live GetConsoleMode). On probe failure: disarm,
close the input channel (this is what stops the reader-thread spin - numRefs drops and
the thread exits), and set `::repl::done {eof <chan>}` so the repl finishes via the
normal eof path; app-punkshell's eof handling then finds CONIN$ unopenable ("broken
pipe") and exits cleanly (heuristic policy, exit 0). repl::start's post-vwait
`chan event $inchan readable {}` deregistration is now guarded for the
watchdog-closed-channel case (previously an unguarded call raised a traceback on this
path). Scheduling state per channel name in `repl::console_watchdog_afterids`;
cancelled after vwait by the arming frame.
Verification (2026-07-12, punk902z kit 2026-07-11 in `src` mode - dev modules; Tcl 9.0.2
kit runtime):
- Same kill procedure post-fix: watchdog message on stderr, orphan EXITED 1.5s after the
conhost kill (worst case is ~watchdog interval + teardown), no traceback. Remaining
teardown noise (disableRaw warning, codethread tsv warning) is cosmetic, printed to an
already-dead console in the real scenario.
- Live-console soak: 25s at the idle prompt (>=4 watchdog probes) - process alive, CPU
delta 0, prompt intact; no spurious trigger.
- Piped stdin (`'set ::x 1' | punk902z src` with PUNK_PIPE_EOF=exit): unaffected, exit 0
(watchdog not armed for pipes).
- runtests (native tclsh 9.0.3): repl consolebackends.test 3/3 pass; punk::console suites
(console/*.test) 88 pass / 1 skip / 0 fail.
- `tclsh src/make.tcl modules` builds clean (provenance warnings = expected dirty tree).
### 2026-07-12 acceptance closed (kits rebuilt, user verification)
All remaining items resolved:
- Kits rebuilt 2026-07-12 with punk::repl 0.5.0 (punkbi, punk91, punk902z). Plain-kit
kill procedure re-verified on the rebuilt punk902z: orphan exited 2.0s after the
conhost kill via the clean eof path (watchdog message, app-punkshell "no console
available", exit 0), no traceback.
- User verified interactive sessions on the rebuilt kits (punkbi, punk91, punk902z,
punk::repl confirmed at 0.5.0): behaviour unchanged with the 5s probe - "all seems
well".
- Tcl 8.6 (punksys) confirmed OUT OF SCOPE by the user 2026-07-12: different console
driver, watchdog deliberately not armed there; reopen as a new goal if an 8.6 orphan
spin is ever observed.
- Wedge-scoring hazard note updated (see Notes - precaution now applies only to
pre-0.5.0 kits).
- Root cause verified independent of punkshell with plain tclsh 9.0.3; upstream ticket
draft written to `TEMP_REFERENCE/tcl9-dead-console-spin-TICKET-DRAFT.md` (filing is a
user decision, not gating this goal).

221
goals/archive/G-041-punkargs-form-matching.md

@ -0,0 +1,221 @@
# G-041 punk::args multi-form matching: automated form selection for parsing and documentation
Status: achieved 2026-07-13
Scope: src/modules/punk/args-999999.0a1.0.tm (parse form selection, arg_error/usage form marking), src/modules/punk/ns-999999.0a1.0.tm (cmdhelp/synopsis closest-form indication), src/tests/modules/punk/args/testsuites/
Goal: for multi-form definitions punk::args determines which form(s) an argument list matches - parse without -form attempts all permitted forms instead of effectively form 0, -form accepts the documented list-of-forms restriction, and the documentation surface indicates the match ('i after cancel <id>' presents the cancel form; 's after cancel someid' marks the closest synopsis) - with explicit single-form restriction retained for callers that require it.
Acceptance: parsing a multiform definition (after-like fixture) without -form succeeds when the args match exactly one form (the pinned GAP tests in forms.test flip to auto-selected results); an argument list matching no form (or several) produces an error naming the candidate forms rather than a form-0 type error; -form with a list of form names/indices restricts parsing to that subset (currently an 'Expected int 0-N or one of ...' error); 'i <cmd> <args...>' and synopsis output indicate the best-matching form(s) for supplied args; explicit -form <single-name-or-index> behaviour is unchanged; the full punk::args and punk::ns suites pass.
## Context
Multi-form definitions (@form directive) describe commands whose argument sets are
orthogonal - the motivating example is the Tcl builtin `after`, documented with several
forms (`after ms ?script...?`, `after cancel id`, `after idle script...`, ...). The
definition and display side works: form_names are recorded, `punk::ns::synopsis` renders
every form (## FORM n headers), and `punk::args::parse -form <name-or-index>` parses
against an explicitly chosen form.
What is missing (characterized 2026-07-08 with an after-like fixture, probe results
pinned as GAP tests in src/tests/modules/punk/args/testsuites/args/forms.test):
- `punk::args::parse` without -form (default `*`) does NOT attempt the other forms:
arguments that match only a non-zero form (e.g. `cancel after#1`) fail with form 0's
type/arity error ("Bad number of values ... Not enough remaining values", the leading
word rejected by form 0's int type). Effectively `*` parses form 0 only.
- `-form` is documented as "Restrict parsing to the set of forms listed" and typed as a
list, but a list of two form names errors with "invalid -form value ... Expected int
0-N or one of '<names>'" - only a single name or index is accepted.
- Documentation surface: `i after cancel <id>` renders help for the default form rather
than determining that the cancel form is the closest match, and `s after cancel someid`
lists all synopses without marking the matching one.
- Internal note: punk::args::parse's own definition is multiform (withid/withdef,
discriminated by literal types) but the proc hand-parses its arguments on the happy
path, so the multiform selection machinery is not exercised internally either.
User direction (2026-07-08): there are times when restricting to a particular form is
appropriate and times when automated selection is better - both must remain expressible.
This is an advanced improvement, deferred; the immediate prerequisite work was making the
punk::args test suite comprehensive so the current behaviour is pinned before any
form-selection logic lands.
## Approach (sketch - to be refined when activated)
1. Form candidacy: for -form * (or a list), attempt each permitted form's parse; classify
outcomes (clean match / match-with-defaults / type-or-arity failure). Exactly one
clean match -> auto-select. None or several -> error naming candidate forms (and for
'several', possibly a deterministic preference rule - e.g. first-declared - recorded
when decided).
2. Literal/leading-word discrimination as a fast path: many multiform commands (after,
parse itself) discriminate on a leading literal - a pre-pass on literal/literalprefix
leader types can pick the form without full parse attempts.
3. -form list support: accept a subset of names/indices as documented; single value
behaviour unchanged.
4. Documentation surface: cmdhelp/arg_error accept the supplied trailing args (cmdhelp
already parses them for goodargs marking) and use the same candidacy logic to pick or
rank forms; synopsis display marks the matching form(s).
5. Error-message quality: a no-form-matches error should show per-form failure reasons
compactly rather than only form 0's.
Downstream consumer note (2026-07-08, G-044): the interactive command-completion/hinting
goal consumes form candidacy on PARTIAL argument lists - as the user types, the hinting
display wants "which forms are still compatible with the words so far" - so the candidacy
mechanism should be exposed as an API that accepts an incomplete argument list and returns
per-form compatibility (not only an all-or-nothing full parse). Design for that consumer
when shaping step 1.
## Progress
- 2026-07-13 increment 1 (engine - punk::args 0.10.0, project 0.12.14): multi-form
candidacy implemented in get_dict. The single-form parse body was extracted verbatim
to private::get_dict_form (no caller-frame use inside - verified; resolve still runs
in the caller's context before selection). -form * / multi-form selections attempt
each permitted form: exactly one clean match auto-selects; none raises 'noformmatch'
naming each candidate form's first-line failure; several raise 'multipleformmatches'
naming the forms. -form list support landed across get_dict/parse/parse_status/
arg_error via the shared private::form_selection resolver (supplied order preserved;
arg_error renders the argument table for the first listed form and marks all listed
forms' synopsis entries). Results gain 'form' (+'formstatus' when candidacy ran);
parse_status 'form' is the matched/best-candidate form and its new 'formstatus' key
is the per-form compatibility surface designed for the G-044 hinting consumer.
DECISIONS RECORDED:
- Several clean matches ERROR rather than silently preferring first-declared (the
acceptance wording; a silent pick could validate against an unintended form and
return a wrong values shape - callers with a preference pass -form).
- Candidate ranking (display/status only - candidacy always from real parse
attempts, no literal fast-path skipping): leading-literal affinity of each form
with the supplied words (agreeing literal(x)/literalprefix(x) leading run scores
up, first disagreement disqualifies), then incomplete before invalid, then
declaration order. Known limits noted in form_literal_affinity: no alignment
through received options; choice-discriminated forms score 0 (refinable).
- Literal-discrimination pre-pass as a parse fast path (approach step 2) deferred
as a perf refinement: correctness must come from real parse attempts, and the
affinity heuristic's word alignment is not exact through options.
Tests: forms.test GAPs flipped (forms_parse_autoselect,
forms_parse_formlist_restriction) + noformmatch ranking/errorcode,
multipleformmatches, shared-prologue arity discrimination, formstatus coverage.
punk::args 198/199 (1 pre-existing skip), punk::ns 53/53, punk::lib 35/35.
Remaining after increment 1: doc-surface increment (cmdhelp best-form presentation
via parse_status form key - cmdhelp's -form default was still 0; synopsis
closest-form marking), live tclcore verification ('lseq 3', switch block form),
@form -synopsis override GAP (adjacent - decide in doc-surface increment).
- 2026-07-13 increment 2 (doc surface - punk::args 0.11.0, punk::ns 0.5.0, project
0.12.15): cmdhelp -form defaults to * and renders the advisory parse's matched/
best-candidate form at both render sites ('i after cancel <id>' presents the cancel
form's table; noformmatch passes the ranked candidates and multipleformmatches the
matching forms to arg_error so all are marked in the synopsis block).
punk::ns::synopsis underlines the form(s) trailing words match ('s after cancel
someid' marks both cancel forms; 's lseq 0 10 2' marks the range form) - ordinal
line-to-form mapping, skipped under alias-currying excess or an explicit -form.
The adjacent @form -synopsis override GAP was fixed and flipped
(forms_form_synopsis_override_rendered): punk::args::synopsis now renders the
documented override in full and summary modes.
Ranking refinement: form_literal_affinity extended to RESTRICTED-choice
discriminators via choiceword_match (shared G-040 resolver) - the tclcore models
express subcommand words as -choices (after's cancel/idle/info), so 'after cancel'
ranks the cancel forms first instead of declaration-order fallback.
REAL-MODEL FINDINGS (live tclcore verification, punk902z + tclsh probes):
- 'lseq 3' -> count form, 'lseq 0 10 2' -> range form, switch separate/block both
auto-select, 'i lseq 0 10 2' renders the range form info-scheme with its synopsis
line underlined.
- 'after cancel someid' is GENUINELY ambiguous in the doc-faithful model (cancelid
'after cancel id' vs cancelscript 'after cancel script ?script?...') - real Tcl
disambiguates semantically by the id's shape, a textual model cannot. Behaviour:
multipleformmatches naming both, first candidate's table, both synopsis lines
marked. Accepted as correct for a doc-faithful model.
- 'lseq 1 count 5' is ambiguous (range + start_count) ONLY because the model's
expr-typed operands accept the word 'count' as an end value - the -type expr
non-validation already recorded for G-055; noted there as a model-tightening
candidate (TIP 746 removes expr operand behaviour in 9.1 anyway).
Suites: punk::args 198/199 (1 pre-existing skip), punk::ns 57/57, full source tree
836 total / 822 passed / 13 skipped / 1 failed = exec-14.3 (the known baseline).
- 2026-07-13 ACHIEVED - acceptance review against each clause:
- Multiform parse without -form succeeds on a unique clean match: the forms.test
GAP pins flipped to auto-selected results (forms_parse_autoselect - ms/cancel/
idle each selected from the words alone; forms_parse_autoselect_shared_prologue
covers arity-discriminated forms sharing a leader block).
- No-match/several-match errors name the candidate forms instead of a form-0 type
error: noformmatch carries every candidate's first-line failure ranked
best-first (forms_parse_noformmatch) and multipleformmatches names the matching
forms (forms_parse_multipleformmatches) - the 'several' outcome is an error by
recorded decision (no silent first-declared preference).
- -form list restriction: a list of names/indices restricts candidacy to the
subset across get_dict/parse/parse_status/arg_error
(forms_parse_formlist_restriction; unrecognised elements keep the invalid
-form error).
- Doc surface indicates the best-matching form(s): 'i after cancel <id>' presents
the cancel form's argument table (cmdhelp_multiform_autoselected_form_presented,
cmdhelp_multiform_noformmatch_best_candidate) and 's after cancel someid'
underlines the matching synopsis line(s)
(synopsis_multiform_marks_matching_form/_unmarked_without_args); live-verified
against the tclcore models on punk902z ('i lseq 0 10 2' range form underlined
info-scheme; 'i after cancel someid' ambiguity named with both cancel forms
marked).
- Explicit single -form behaviour unchanged: the pre-existing explicit-form tests
(forms_parse_explicit_form_by_name/_by_index) pass unmodified.
- Full punk::args and punk::ns suites pass (198/199 with 1 pre-existing skip;
57/57); full source tree 822/836 with the exec-14.3 baseline as the only
failure.
Adjacent extra: the @form -synopsis override GAP flipped
(forms_form_synopsis_override_rendered). The G-044 candidacy-API design note is
satisfied by parse_status's formstatus key (per-form compatibility on partial
arglists). Deferred as refinements (not acceptance): literal-discrimination
fast path for parse performance; affinity alignment through received options;
punk::args::synopsis -form list support (single-form restriction retained there).
## Alternatives considered
- Documenting alternate forms as separate subhelp choiceinfo entries (the trace-style
pattern) - works only when a literal subcommand word exists and fragments a command's
documentation into pseudo-subcommands; rejected as the general answer.
- Requiring callers to always pass -form - status quo for parsing, but unusable for the
interactive doc surface ('i after cancel <id>') where the user supplies args, not form
names.
## Notes
- Probe script: scratchpad formprobe.tcl (2026-07-08); findings encoded in
src/tests/modules/punk/args/testsuites/args/forms.test (GAP-labelled tests reference
this goal and flip when it is implemented).
- Related: G-040 (choice aliasing/doc-lookup parity - same "parser and doc surface must
agree" principle); punk::args::parse -cache interaction with per-form attempts will
need review (cache key already includes selected form).
- Incidental find during the same probing: an uncommented debug `puts stderr
">>>_get_dict_can_assign_value NOT alloc_ok..."` fired on every failed clause type
assignment (punk/args ~l.6186; its happy-path twin was already commented). Fixed
2026-07-08 (punk::args 0.2.3) - noted here because form-attempt logic will exercise
that failure path heavily.
- 2026-07-12 prework probes (user concerns raised before activation - real-builtin
evidence beyond the after fixture; results from the punk902z kit, Tcl 9.0.2):
- Auto-selection gaps confirmed on real commands: 'lseq 3' (count form),
'lseq 1 count 5 ?by 2?' (start_count form) and 'switch abc {a {} default {}}'
including options+block ('switch -regexp -matchvar m abc {...}') all fail under
default form-0 parsing but parse correctly with an explicit -form. The per-form
models are doc-faithful: lseq.n's three synopsis lines map 1:1 to the modelled
range/start_count/count forms; switch separate/block both verified per-form.
- PREREQUISITE independent of form selection: the value allocator mishandles an
optional single-word choice value followed by a required value plus a trailing
optional-member clause. lseq range-form arglists WITHOUT the ../to noise word
but WITH a step ('0 10 2', '0 10 by 2', '1 5 by 0') fail IN the range form (the
error blames '..|to'), while '0 to 10 2' and '0 .. 10 by 2' parse fine. No other
lseq form accepts those arglists, so form auto-selection alone could not make
'i lseq 0 10 2' work. The allocator prerequisite became G-071 and was FIXED
the same day (achieved 2026-07-12, punk::args 0.9.0 allocation choice screen -
see goals/archive/G-071-punkargs-optional-allocation.md): the lseq matrix now
parses in-form, leaving form auto-selection as this goal's remaining gap for
the 'lseq 3' / switch-block-form cases. ::if's noise-word clauses
('?literal(then)?' mid-clause, '?literal(else)?' clause-leading) parse correctly
in the same probes, so the failure is specific to the optional standalone value
+ trailing optional-member clause combination, not optional clause members
generally.
- CORRECTED (G-071 closeout): parse_status DOES accept -form - positioned
before the withid/withdef tail per its documented synopsis; the probe had
passed it trailing. Behaviour pinned in allocation.test
(parsestatus_form_option_order). The candidacy API and the G-044 consumer
can build on the existing surface (list-of-forms support remains this
goal's -form list item, as for parse).
- Over-acceptance divergences (model valid where the real command errors) recorded
for the modelability list in G-055's detail file - not this goal's scope.
- Incidental fix during probing: another unconditional debug puts on the clause
type-check path (private::get_dict_can_assign_value "checking tp ...", fixed as
punk::args 0.8.2) - companion to the 0.2.3 find noted above; form-attempt logic
will exercise these paths heavily.
- Archived-goal references in this file: G-040 achieved 2026-07-08 (goals/archive/G-040-punkargs-choicealiases.md).

263
goals/archive/G-045-punkargs-authoring-ergonomics.md

@ -0,0 +1,263 @@
# G-045 punk::args definition authoring ergonomics: record continuation, @cmd unindented fields, constructed-definition normalization
Status: achieved 2026-07-12
Scope: src/modules/punk/args-999999.0a1.0.tm (record parsing in resolve, tstr interplay, arg_error @cmd rendering), src/tests/modules/punk/args/testsuites/ (rendering.test/defquoting.test as the safety net), src/modules/punk-999999.0a1.0.tm (::punk::helptopic::define_docs de-hacked as the consumer proof)
Goal: authoring punk::args definitions no longer requires backslash line-continuations or ad-hoc workarounds for multi-line records and constructed definitions - a parser-recognised record-continuation mechanism (candidate: an unquoted trailing -& token, with the detail file recording the collision analysis and the element-count disambiguation alternative), -unindentedfields honoured for @cmd fields, and constructed (string-built) definitions able to opt into the same whole-block indent normalization file-style definitions get - with the container quoting rules (braced=literal, quoted=Tcl backslash semantics, \$\{ escape) promoted from defquoting.test into the define documentation.
Acceptance: a definition using the chosen record-continuation mechanism parses identically to its backslash-continuation equivalent (existing definitions unchanged - continuation is additive), with the token's collision rules documented and an escape/rejection story for values that legitimately match it; @cmd -help/-summary honour -unindentedfields (the rendering.test GAP rendering_unindentedfields_cmd_help_GAP flips to aligned); a constructed definition can request whole-block normalization so embedded continuation indentation behaves as in file-style definitions (the rendering_constructed_def_indent_characterization expectations updated to the chosen semantics), and ::punk::helptopic::define_docs drops its manual pre-normalization to prove it; the quoting rules from defquoting.test appear in the punk::args::define -help documentation; the full punk::args suite (128 tests incl. the rendering invariants: nesting independence, relative-indent preservation) passes with GAP tests flipped, none weakened.
## Context
Drafted 2026-07-09 after the tests-first characterization pass (user direction: implement
tests before changes in this area). The pain points, in the user's framing:
- The define-block syntax deliberately avoids Tcl dict syntax for human readability, which
led to backslash line-continuations and a complex interplay between record parsing in
punk::args::resolve and tstr (especially ${...} expansions keeping whitespace aligned
during multiline substitutions).
- Constructed (string-built) definitions - ::punk::helptopic::define_docs and
punk::args::ensemble_subcommands_definition are the in-tree examples - have poor
ergonomics for getting indentation right: unlike file-style definitions they receive no
whole-block indent normalization (proven: rendering.test
rendering_constructed_def_indent_characterization - continuations at exactly 4 spaces
align, other depths leak), so builders pre-normalize by hand (undent/trim hacks,
\n-relative authoring).
- A key property of normal definitions that must be preserved: they do not depend on the
indentation of the code block they appear in (proven and pinned:
rendering_nesting_independence_plain / _tstr).
- The user's experiment used an ad-hoc `--` flag followed by a braced line-spanning empty
string as a de-facto record continuation - functional but not designed-for, and
unsatisfying.
## The -& record-continuation candidate (implemented 2026-07-12 - see Progress increment 3)
The user's proposal: a special token the record parser recognises as record continuation,
e.g. an unquoted `-&` at end of line. Design considerations recorded for implementation:
- Collision risk is low but not zero (a value or flag legitimately spelled -&). Candidate
rules: only an UNQUOTED trailing `-&` at end of a record line counts; and/or use the
element count of the assembled record to disambiguate (an odd element count where pairs
are expected implies the trailing token is a continuation marker, not a value). The
detail decision must include an escape story for a literal trailing -& value (bracing it
suffices if 'unquoted' is part of the rule).
- Detection must happen in the record parser (resolve) before/alongside line splitting -
the same place backslash-continuation assembly effectively happens via Tcl's own parse
of braced blocks today. Unlike backslash-newline, -& survives inside braced blocks of
CONSTRUCTED definitions too - which is precisely why it helps: string-built defs cannot
use backslash-newline ergonomically (it is consumed by the building code's own quoting).
Decision as implemented (punk::args 0.7.0):
- Rule: unquoted trailing `-&` only - the token must be a bare word preceded by
whitespace (or be the whole line) and be the last element on the line; trailing
whitespace after it is tolerated (deliberately more forgiving than raw
backslash-newline, whose invisible-trailing-whitespace failure is a classic trap).
- Escape story: brace or double-quote a literal trailing -& value ({-&}) - the raw line
then ends with the closing delimiter and never matches. A -& mid-line, as a word
suffix (abc-&), or on a line inside a still-open braced/quoted value is data.
- Element-count disambiguation: NOT implemented. The positional rule is deterministic
and locally decidable per line; element counting would require assembling and
list-parsing the record first (fragile against in-progress records) and gives
confusing action-at-a-distance when a distant key/value slips the count. Rejected
rather than deferred - the escape story covers the residual collision (a literal
trailing -default -& must be braced).
- Assembly semantics: byte-identity with the backslash equivalent. Key finding: for
braced (file-style) definitions Tcl itself collapses backslash-newline plus following
whitespace to a single space BEFORE the text reaches the record splitter - so -& is
implemented the same way (token dropped, single-space join, next line's leading
whitespace collapsed), making the assembled record byte-identical to its
backslash-continued twin. Proven by parse-result and rendered-table equality in
recordcontinuation.test.
- Reserved-word consequence: an argument cannot be NAMED -& via a record line ending in
the bare token (e.g. a lone '-& ' line would read as a continuation). Any such need is
met by bracing. Considered acceptable and documented here rather than guarded in code.
## @cmd -unindentedfields
Accepted but ignored today (arg_error's cmd_info rendering carries an '#unindentedfields ?'
todo; pinned by rendering_unindentedfields_cmd_help_GAP: left-margin-authored @cmd help
renders its first line +4 vs continuations). Honouring it makes left-margin authoring
available for @cmd -help/-summary as it already is for argument -help and -choicelabels.
## Constructed-definition normalization
Options to decide at implementation (record the choice):
- an explicit define option/directive requesting whole-block undent of the supplied text
- or automatic common-prefix normalization when a definition arrives as a single text
block (risk: changing existing constructed defs' rendering - the characterization test
documents current expectations to update deliberately)
::punk::helptopic::define_docs currently pre-normalizes by hand (undent/trim + \n-relative
help strings + -unindentedfields) - it converts to the new mechanism as the proof, and the
user's uncommitted experimental hack in punk.tm is superseded.
## Quoting documentation
defquoting.test pinned the container rules (braced values fully literal - $, [], two-char
\n, bare backslashes; quoted values get Tcl backslash semantics - \n newline, \\ -> \ -
with $/[] still literal; \$\{...\} renders a literal ${...} in tstr-processed blocks).
Promote these rules into the punk::args::define -help documentation so documenters (e.g.
punk::imap4's {\Deleted}/{$MDNSent} choice values) have them stated, not just tested.
## Alternatives considered
- Keeping backslash continuations + manual pre-normalization as the documented way -
rejected: the friction is proven by two in-tree constructed-def workarounds and the
user's punk.tm experiments.
- Tcl dict syntax for definitions - rejected long ago by design (readability).
- The ad-hoc `--` + braced-gap trick as the blessed continuation - rejected: exploits
parser behaviour that was never a contract.
## Notes
- Safety net: src/tests/modules/punk/args/testsuites/args/rendering.test (15 tests:
nesting independence, relative-indent preservation, unindentedfields, constructed-def
characterization, multiline tstr insertions, @dynamic) and defquoting.test (container
quoting rules). GAPs to flip here: rendering_unindentedfields_cmd_help_GAP,
rendering_constructed_def_indent_characterization (expectations updated to chosen
semantics).
- Related: G-046 (deferred -help resolution and rendering fixes - same code
neighbourhood; sequencing between the two goals is free but they touch resolve
together), G-044 (completion consumes the documentation corpus these ergonomics grow).
- Session records 2026-07-09: probe scripts renderprobe*.tcl (scratchpad), the user's
experimental define_docs hack (uncommitted punk.tm working-tree diff at the time of
drafting - superseded by this goal when implemented).
- Archived-goal references in this file: G-046 achieved 2026-07-10 (goals/archive/G-046-punkargs-deferred-help-and-fixes.md).
## Progress
### 2026-07-12 increment 1: @cmd -unindentedfields honoured (punk::args 0.6.1)
- arg_error's cmd-help display transform (undent " "+help, max 4 - the
'#unindentedfields ?' todo site) is now gated by "-help" membership in the @cmd
line's -unindentedfields list, mirroring the existing per-argument gate. The
single transform site feeds both the table and string renderers.
- rendering_unindentedfields_cmd_help_GAP flipped to
rendering_unindentedfields_cmd_help (expects aligned; both renderers measured).
- @cmd -summary: verified no renderer applies an indent transform to -summary (it
is only used inline in synopsis "# ..." lines), so -unindentedfields membership
for -summary is accepted and vacuously honoured - nothing to gate. This
satisfies the acceptance's "-help/-summary honour" clause for -summary.
- Blast radius checked before the change: no in-tree definition sets
-unindentedfields on a @cmd line, so no existing rendering changed.
- define doc for -unindentedfields now states where the option is valid
(argument lines and the @cmd directive's -help).
- Verified (tclsh 9.0.3): full punk::args suite 175 pass / 1 pre-existing skip /
0 fail; punk::ns suite 53/53 (arg_error consumer); make.tcl modules builds
clean.
### 2026-07-12 increment 2: 'i help' alignment via -unindentedfields (punk 0.2.4)
- ::punk::helptopic::define_docs now authors its help text at the left margin
and declares -unindentedfields {-help} on the generated @cmd line (using
increment 1's gate) and on the topic argument line (a gate that existed all
along but was never applied here). Verified in punk902z src: the Description
block's +12 continuation leak and the topic Help first-line +4 are both gone;
'i help' renders flush end-to-end. punk::ns suite 53/53; make.tcl modules
clean.
- Decision (user, 2026-07-12): when the constructed-def normalization mechanism
lands, define_docs converts from its interim left-margin authoring to
indented-plus-normalized authoring - it remains the acceptance's consumer
proof as written. Left-margin authoring via -unindentedfields stays a
supported style proven by tests (rendering_unindentedfields_arg_help,
rendering_unindentedfields_cmd_help), but it is not the preferred style for
define_docs itself.
### 2026-07-12 increment 3: -& record continuation (punk::args 0.7.0)
- Implemented in private::split_definition_records per the decision recorded in
"The -& record-continuation candidate" section above (unquoted trailing token,
brace escape, element-count alternative rejected, byte-identical assembly to
the backslash equivalent).
- First implementation attempt kept a literal backslash-newline in the assembled
record and failed: braced definitions never contain backslash-newline by the
time they reach the splitter (Tcl pre-collapses it), and downstream record
handling assumes the collapsed shape. The collapse-to-single-space rewrite is
the correct equivalence and is what landed.
- New testsuite recordcontinuation.test (6 tests): backslash-twin parse+render
equality (same-length ids so table geometry matches), constructed-def chaining
incl. into a multi-line quoted value, braced {-&} escape, mid-line -&,
word-suffix abc-&, -& inside still-open braces.
- define doc documents -& alongside the backslash continuation paragraph.
- Verified (tclsh 9.0.3): full punk::args suite 181 pass / 1 pre-existing skip /
0 fail; full source-tree suite (splitter regression sweep) 815 total, 801
pass, 13 skip, 1 fail = exec-14.3 only (the known pre-existing core-test
baseline) - zero regressions.
- tclparser considered and rejected for the splitter (user question 2026-07-12):
parse command mis-tokenizes the dialect's legitimate unbalanced-bracket ANSI
data inside quoted values (info complete gets a throwaway ansistripped copy;
parse would need strip+range-remapping), the punk semantics (base-indent line
trimming, -&) sit outside Tcl's grammar either way, and a binary dependency in
punk::args inverts G-004 for the module that must load everywhere. tclparser
remains right for real-script analysis (punk::lib/G-019). Candidate flagged:
dev-time lint cross-checking splitter boundaries against parse where the
binary is available.
### 2026-07-12 increment 4: @normalize constructed-def normalization (punk::args 0.8.0, punk 0.2.5)
- Mechanism (user-approved 2026-07-12): bare @normalize directive (like @dynamic;
options are an error), detected as a resolve pre-pass over the split records.
Semantics user-confirmed as re-base-to-+4; narrowed during implementation to
BLOCK-FORM values only (first line whitespace-only): the structural first
newline and a whitespace-only trailing line are dropped (the user's
leading-newline ergonomics question - resolved as automatic), the content
lines' common leading whitespace is the base, first content line unindented
fully, subsequent lines re-based to 4 spaces with deeper relative indents
preserved. -unindentedfields fields exempt.
- Why block-form only: head-form values have an unknowable base - continuations
uniformly at 6 may be base-4 with the deliberate +2 relative convention (P2)
or base-6 flush; common-prefix re-basing flattens the former. The idempotence
test caught exactly this on a file-style def, forcing the narrowing. Block
form is unambiguous (the first content line carries the full base), and
head-form untouchedness makes @normalize a proven no-op on conforming
file-style definitions.
- Consumer proof: define_docs converted from interim left-margin authoring to
indented block-form values + @normalize (per the increment 2 decision);
-unindentedfields declarations dropped; 'i help' and 'i help_chunks'
verified aligned in punk902z src (including the blank-line separator in the
combined basehelp+extra block).
- rendering_constructed_def_indent_characterization stays pinned as the
deliberate unopted default, its description/header updated to reference
@normalize as the opt-in remedy (the acceptance's "expectations updated to
the chosen semantics" - the chosen semantics being opt-in, unopted
behaviour is contract, not gap).
- New testsuite normalize.test (5 tests): block-form re-base, block-form
left-margin, head-form untouched boundary, -unindentedfields exemption
(structural newline kept byte-exact), idempotence on file-style defs.
- Verified (tclsh 9.0.3): punk::args suite 186 pass / 1 pre-existing skip / 0
fail; punk::ns suite 53/53; full source-tree suite 820 total, 806 pass, 13
skip, 1 fail = exec-14.3 only (known pre-existing core-test baseline) - zero
regressions; make.tcl modules builds clean; 'i help'/'i help_chunks' verified
aligned in punk902z src.
### 2026-07-12 increment 5: define quoting documentation (punk::args 0.8.1) - acceptance complete
- The container quoting rules pinned by defquoting.test now appear in the
punk::args::define -help documentation (-help key section): braced values
fully literal; double-quoted values with Tcl backslash semantics at record
parse ($ and [] literal, no substitution outside tstr placeholders); the
backslash-escaped placeholder idiom. Rendered output of the new section
verified in punk902z src - the meta-escapes display as intended (\n as two
characters, \Deleted, doubled backslash, literal dollar-brace placeholders).
- punk::args suite 186 pass / 1 pre-existing skip / 0 fail (tclsh 9.0.3).
Acceptance review at flip (2026-07-12): all criteria satisfied.
- -& record continuation parses identically to its backslash equivalent
(recordcontinuation.test byte-equality; additive - existing definitions
unchanged); collision rules and the {-&} escape documented (increment 3).
- @cmd -help/-summary honour -unindentedfields;
rendering_unindentedfields_cmd_help_GAP flipped to aligned (increment 1;
-summary vacuously honoured - no renderer transforms it, verified).
- Constructed definitions can request whole-block normalization (@normalize,
block-form values); rendering_constructed_def_indent_characterization
updated to the chosen opt-in semantics (unopted behaviour pinned as
contract); ::punk::helptopic::define_docs dropped its manual
pre-normalization - first to interim left-margin authoring (increment 2),
then to indented blocks + @normalize as the consumer proof (increment 4).
- Quoting rules from defquoting.test appear in the define -help documentation
(increment 5).
- Full punk::args suite passes with GAP tests flipped, none weakened: 186
pass / 1 pre-existing skip / 0 fail (suite grown from 128 to 187 tests via
the new recordcontinuation.test and normalize.test); full source-tree suite
showed zero regressions at increments 3 and 4 (exec-14.3 known baseline
only). Verified on native tclsh 9.0.3 plus punk902z src for rendered
output; punk 0.2.5 / punk::args 0.6.1-0.8.1 shipped in project versions
0.12.4-0.12.8.

39
goals/G-051-cmdinfo-pseudo-and-prefix.md → goals/archive/G-051-cmdinfo-pseudo-and-prefix.md

@ -1,6 +1,6 @@
# G-051 cmdinfo truthful cmdtype for doc-only pseudo-commands and space-form docid prefix parity
Status: proposed
Status: achieved 2026-07-13
Scope: src/modules/punk/ns-999999.0a1.0.tm (cmdinfo, cmd_traverse), src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test, src/tests/modules/punk/ns/testsuites/ns/cmdflow.test
Goal: cmdinfo reports a distinct cmdtype (e.g 'doconly') when resolution lands on a punk::args id with no corresponding real command instead of today's 'notfound', and the space-delimited docid jump accepts the same word prefixes the parser accepts - via the shared punk::args::choiceword_match resolver, not a second matching rule - so 'i string is tr' documents what 'string is tr' actually executes.
Acceptance: the pinned GAP tests flip: cmdhelp_GAP_pseudo_command_cmdtype_notfound and cmdhelp_GAP_string_is_true_pseudo report the new cmdtype with the docid unchanged, cmdhelp_GAP_spaceform_docid_prefix_not_honoured and cmdhelp_GAP_string_is_prefix_not_honoured resolve the child docid from a prefix exactly when parse accepts that prefix (honouring -choiceprefix, -choiceprefixdenylist, -choiceprefixreservelist and -choicealiases per G-040 parity); consumers of cmdinfo's cmdtype (cmdhelp, synopsis, eg) handle the new value with no behaviour change for real commands; cmdflow.test and the non-GAP cmdhelp.test tests pass unchanged.
@ -53,3 +53,40 @@ cmdhelp_GAP_string_is_prefix_not_honoured (real tclcore docs).
- 'string is' has a second wrinkle out of scope here: its per-class ids are generated
from the parent's -choicelabels at tclcore load time; prefix parity must come from the
walk, not from generating extra prefix ids.
## Progress
### 2026-07-13 implemented (punk::ns 0.4.0, punk::lib 0.4.1) - acceptance complete
- doconly cmdtype: cmdinfo classifies cmdwhich-notfound + non-empty docid as
'doconly' after the curried-alias retry. Design decision (anticipated in
Context): the TclOO documented-method case ("<class> docmeth" docids) ADOPTS
doconly rather than a method-specific value - G-052 may refine when it takes
up method-level autodef; cmdhelp_oo_documented_method's docid assertion holds
unchanged.
- Consumers audited: cmdhelp (docid-driven; its origintype switch only branches
on 'script'; doconly implies docid non-empty so the undocumented fallback is
unreachable for it), synopsis and eg (docid-driven), cmdtrace (tests only for
'proc'), punk::help topic fallback (uses cmdwhich whichtype, not cmdinfo),
punk::lib tclscript analysis (doconly accepted alongside notfound at the
subcommand-walk stop condition and the bucketing switch - identical behaviour
preserved, the 'string is xdigit' comment there anticipated exactly this).
- Space-form prefix parity: cmd_traverse's exact-word space-form jump gains a
choiceword_match retry (current level's choices-bearing first leader; the
shared G-040 resolver, no second matching rule) resolving the canonical word
before the id_exists retest; resolvedargs records the canonical, mirroring
parse normalization.
- GAP flips (renamed): cmdhelp_pseudo_command_cmdtype_doconly,
cmdhelp_spaceform_docid_prefix_honoured (extended with ambiguous/unknown-word
stays-at-parent guard), cmdhelp_string_is_true_pseudo_doconly,
cmdhelp_string_is_prefix_honoured.
- Live verification (punk902z src, Tcl 9.0.2): 'i string is tr' resolves docid
::tcl::string::is true (doconly) and renders byte-identical to
'i string is true' except the failure banner echoing the typed words
("for string is tr" vs "for string is true" - the -caller echo of user
input, considered correct); 'string is bool' -> boolean; ambiguous 'd' and
unknown 'zz' stay at the parent docid exactly as parse rejects them.
- Suites (tclsh 9.0.3): punk::ns 53/53 (cmdflow + non-GAP cmdhelp unchanged),
punk::args 193 pass / 1 pre-existing skip, punk::lib 35/35; full source-tree
suite 827 total, 813 pass, 13 skip, 1 fail = exec-14.3 only (known
pre-existing core-test baseline) - zero regressions.

130
goals/archive/G-071-punkargs-optional-allocation.md

@ -0,0 +1,130 @@
# G-071 punk::args value-allocation correctness for optional elements (lseq-class arglists) + parse_status -form
Status: achieved 2026-07-12
Scope: src/modules/punk/args-999999.0a1.0.tm (get_dict value allocation, private::get_dict_can_assign_value, parse_status), src/tests/modules/punk/args/testsuites/args/ (new allocation characterization suite), src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm (::lseq as the proving consumer)
Goal: argument lists that are valid for a single form parse correctly when optional standalone values and optional-member clauses are skipped or filled in any documented combination - specifically the lseq range shape (an optional single-word choice value between required values, followed by a trailing optional-member clause), where today the allocator force-feeds a word to the skippable optional and fails ('lseq 0 10 2', '0 10 by 2', '1 5 by 0' all fail in-form while '0 to 10 2' parses) - and parse failures blame the genuinely failing element rather than an unrelated optional; parse_status gains -form so per-form status probing works.
Acceptance: a new allocation characterization suite drives the lseq range matrix under explicit -form range - '0 10', '0 10 2', '0 10 by 2', '0 to 10 2', '0 .. 10 by 2', '1 5 by 0' parse per the lseq.n grammar (the three currently-failing cases fixed) and '0 10 2 4' still fails - plus reduced fixtures isolating the shape (optional choice value between required values + trailing optional-member clause) independent of the moduledoc; the ::if noise-word cases (mid-clause ?literal(then)?, clause-leading ?literal(else)?) keep passing - no regression to optional clause members generally; a genuinely invalid arglist's error names the failing element (the '..|to' misblame case pinned fixed); punk::args::parse_status accepts -form (single form name/index at minimum, consistent with parse) with its status structure reporting the form used; full punk::args and punk::ns suites pass with no expectations weakened; before/after results for the probe matrix recorded in this file.
## Context
Found 2026-07-12 during G-041 activation prework (user concern: variable-length
clauses with optional elements). Real-vs-model probing of the tclcore moduledoc
showed the per-form models are doc-faithful (lseq.n's three synopsis lines map 1:1
to the modelled range/start_count/count forms; if/switch/try likewise verified),
but the range form fails IN-FORM whenever the ../to noise word is absent and a
step is present - the allocator insists on assigning the word after start to the
optional '..|to' choice value (whose choices then reject it) instead of skipping
the optional and letting the trailing "by step" clause (-type
{?literalprefix(by)? number|expr}) absorb the remainder. The start_count form
proves the optional-member clause itself can absorb a lone word ('1 count 5 2'
parses as {by step} {{} 2}) when preceding words are pinned by a literal - the
failure is the combination with a skippable standalone optional value.
No other lseq form accepts '0 10 2', so G-041's form auto-selection cannot fix
'i lseq 0 10 2' without this - making this goal a G-041 prerequisite. Probe
results are recorded in goals/G-041-punkargs-form-matching.md Notes (2026-07-12).
parse_status -form: per-form probing during the same session had to fall back to
punk::args::parse + catch because parse_status does not accept -form. The
form-candidacy API sketched in G-041 (and the G-044 partial-arglist consumer)
wants exactly this surface, so it lands here where the characterization suite
needs it first.
## Approach
- Characterize first: reduced fixtures pinning current allocation behaviour for
the failing shape (and the working ::if shapes as regression guards), then fix
the allocator - likely in the lookahead/backtrack decision where
get_dict_can_assign_value rejects an optional's candidate word: rejection of an
optional element should yield the word to subsequent elements/clauses rather
than failing the parse when a consistent allocation exists.
- Blame quality: when no consistent allocation exists, prefer reporting the
element whose constraint actually failed over the first optional tried.
- The G-041 detail file notes the form-attempt logic will exercise these paths
heavily; landing this first keeps that goal's failures meaningful.
## Progress
### 2026-07-12 increment 1: characterization suite + retreat-path debug silencing (punk::args 0.8.3)
- New allocation.test (7 tests): reduced lseq-range fixture (start ?sep? end
?by-step clause? - no moduledoc dependency) with the current mis-allocation
pinned as GAPs (allocation_range_optskip_bare_step_GAP '1 2 3' -> invalid/sep,
allocation_range_optskip_by_clause_GAP '1 2 by 3' -> incomplete/end,
allocation_range_excess_invalid_misblame_GAP '1 2 3 4' -> invalid but blaming
'sep'), noise-word variants pinned as regression guards (all parse with
expected receivednames), the if-shape noise-word guards (mid-clause
?literal(then)?, clause-leading ?literal(else)? - 7 valid + 1 invalid case),
and parsestatus_form_option_GAP pinning that -form raises today.
- Fixture probing confirmed the reduced shape reproduces the moduledoc failures
exactly (same badarg, same status classes), so the fix can iterate against the
fixtures alone.
- Silenced four more unconditional debug puts on get_dict's allocation retreat
paths ((111)/(222)/(333)/(444)) - they fired on stderr during NORMAL
successful parses at every optional-skip retreat (visible when the fixture
probes ran; also polluting interactive shell parses of if/lseq-modelled
commands). Behaviour unchanged - the retreat logic they marked is this goal's
fix target.
- Verified (tclsh 9.0.3): full punk::args suite 193 pass / 1 pre-existing skip /
0 fail.
### 2026-07-12 increment 2: allocation choice screen (punk::args 0.9.0) - acceptance complete
- Root cause: get_dict_can_assign_value blanket-satisfied the member check for any
argument with choices ("each tp in the clause is just for validating a value
outside the choice-list when -choicerestricted 0" - applied to restricted sets
too), so an optional choice value consumed any word whenever arity permitted.
Arity always permitted here because (a) the tail-reservation scan cannot see the
by-step clause (its lsearch for literal* misses the optional-wrapped
?literalprefix(by)?), and (b) tail_needs counts only required tail elements.
- Fix: a choiceword_match allocation screen (the shared G-040 implementation, so
allocation acceptance cannot diverge from parse acceptance - exact/alias/prefix/
nocase; -choicemultiple words screened per list member within min/max), applied
ONLY where allocation has an alternative: -optional arguments and further
occurrences of -multiple arguments. Two refinements forced by the existing
suites during implementation, both kept as design decisions:
- REQUIRED arguments are not screened - the word must fill them regardless, and
screening only masked validation's informative choiceviolation as a
missingrequired* (caught by parsestatus.test/cmdhelp.test pins).
- -choicemultiple list-valued words are screened member-wise (caught by
choices.test choicemultiple pins).
The screen compares raw words (no ansistrip): an ansi-wrapped choice word on an
optional argument would be skipped where validation would accept it - accepted
edge, recorded in the code comment.
- Correction of an increment-1 mis-premise: parse_status ALREADY accepts -form -
positioned before the withid/withdef tail per its documented synopsis; the
probing failure was a trailing -form (argument-order mistake). The GAP was
replaced by parsestatus_form_option_order pinning correct-order acceptance
(with the status structure's form key) and trailing-order rejection. The
acceptance clause "parse_status accepts -form ... reporting the form used" was
therefore already satisfied; verified with a multiform fixture (form=beta
reported). The Goal line's "parse_status gains -form" framing was inaccurate -
no code change was needed or made for it.
- Before/after (reduced fixture, tclsh 9.0.3): '1 2 3' {0 invalid sep} ->
{1 valid {start end {by step}}} with clause {{} 3}; '1 2 by 3'
{0 incomplete end} -> {1 valid ...} with clause {by 3}; '1 2 3 4'
{0 invalid sep} -> {0 invalid {}} (same excess-values rejection as the
noise-word twin); prefix semantics preserved ('1 2 b 3' -> clause {by 3},
'1 t 2 3' -> sep normalized to 'to'); all noise-word variants and all if-shape
guards unchanged.
- lseq moduledoc matrix (punk902z src, Tcl 9.0.2): explicit -form range now
parses '0 10 2', '0 10 by 2', '1 5 by 0' per lseq.n (previously in-form
failures); count and start_count forms unchanged; default-form parsing of
'0 10 2' now valid end-to-end (the original 'i lseq 0 10 2' complaint).
Remaining real-vs-model divergences all trace to G-041 form selection or the
-type expr non-validation recorded in G-055's notes - not allocation.
- Verified (tclsh 9.0.3): punk::args suite 193 pass / 1 pre-existing skip / 0
fail (allocation GAPs flipped; the two required-choice-arg pins and the
choicemultiple pins unweakened); punk::ns suite 53/53; full source-tree suite
827 total, 813 pass, 13 skip, 1 fail = exec-14.3 only (known pre-existing
core-test baseline) - zero regressions; make.tcl modules builds clean.
## Notes
- Related: G-041 (this is its prerequisite; probe evidence in its Notes), G-053
(occurrence-arity work touches adjacent allocation machinery), G-055 (lseq
parity pins; TIP 746 note there makes lseq operand types version-conditional -
this goal's matrix should derive expectations from the live interpreter).
- Incidental fixes already landed during the probing session: unconditional debug
puts on the clause type-check path (punk::args 0.8.2), and an earlier companion
(0.2.3, noted in G-041).

2
punkproject.toml

@ -1,4 +1,4 @@
[project]
name = "punkshell"
version = "0.12.1"
version = "0.12.20"
license = "BSD-2-Clause"

18
src/modules/punk-999999.0a1.0.tm

@ -8494,7 +8494,12 @@ namespace eval punk {
}
#keep help-text lines manually folded (~70 cols) - the usage tables don't yet
#wrap to terminal width, so line lengths here directly set the table width
set basehelp {Help system for the punk shell.
#help text is authored as indented blocks (structural leading newline) and
#the generated definition declares @normalize (G-045): block-form
#multi-line values are re-based to the file-style convention at resolve
#time - no manual undent/trim and no left-margin authoring in this builder
set basehelp {
Help system for the punk shell.
With no arguments - an overview of some key shell
commands is displayed.
When the first argument is a recognised topic - help
@ -8504,12 +8509,19 @@ namespace eval punk {
command info (type and synopsis) is shown for a
resolvable command, or the resolved path for an
external executable.}
set topichelp "Help topic, or command words for basic command info.\nTopics accept their aliases and unique prefixes\n(some short words deliberately fall through to command lookup)."
set topichelp {
Help topic, or command words for basic command info.
Topics accept their aliases and unique prefixes
(some short words deliberately fall through to command lookup).}
set specs [list]
lappend specs ::punk::help help "Punk shell help system." ""
lappend specs ::punk::help_chunks punk::help_chunks "Punk shell help system - content as {channel text} chunks." "\n\nhelp_chunks returns the help content as a list of\n{channel text} chunks rather than emitting it."
lappend specs ::punk::help_chunks punk::help_chunks "Punk shell help system - content as {channel text} chunks." {
help_chunks returns the help content as a list of
{channel text} chunks rather than emitting it.}
foreach {id name summary extra} $specs {
set def ""
append def "@normalize" \n
append def "@id -id $id" \n
append def "@cmd -name $name -summary \"$summary\" -help \"$basehelp$extra\"" \n
append def "@leaders -min 0 -max -1" \n

4
src/modules/punk-buildversion.txt

@ -1,6 +1,8 @@
0.2.3
0.2.5
#First line must be a semantic version number
#all other lines are ignored.
#0.2.5 - G-045: ::punk::helptopic::define_docs converts from interim left-margin authoring to indented block-form values under the new punk::args @normalize directive (the constructed-definition normalization consumer proof): basehelp/topichelp/help_chunks-extra are braced indented blocks with a structural leading newline, the generated definitions declare @normalize, and the -unindentedfields declarations from punk 0.2.4 are dropped. Rendered 'i help'/'i help_chunks' output unchanged (verified aligned incl. the blank-line separator in help_chunks).
#0.2.4 - G-045: 'i help' usage table alignment - ::punk::helptopic::define_docs authors its help text at the left margin and declares -unindentedfields {-help} on both the generated @cmd line (honoured as of punk::args 0.6.1) and the topic argument line. Previously the @cmd -help braced literal carried ~16 spaces of source indent into the constructed definition (no whole-block normalization), rendering Description continuations +12 right of the first line, and the \n-relative topic -help rendered its first line +4 (the injected display prefix). Both blocks now render flush. Text content unchanged (manual ~70-col folding retained).
#0.2.3 - punk::help topic definition adopts punk::args -choicealiases (G-040): choices are the four canonical topics with registry aliases folded ('i help' shows one entry per topic with an (alias:...) note), unique prefixes of topics and aliases accepted, minimum-prefix policy per user decision recorded in ::punk::helptopic (denylist {help}: h/he/hel stay command words; reservelist {c to tc}: fall through to command lookup); unrecognised words still fall through to basic command info; argless 'help' overview unchanged
#0.2.2 - documentation-only: helptopic argdoc help texts manually folded (~70 cols) and the generated ::punk::help topic choices grid set to -choicecolumns 2 - 'i help'/'i help <topic>' tables now render at reasonable widths (61-68 cols, was ~160); usage tables don't yet wrap to terminal width so source line lengths set table width; argless 'help' overview output unchanged (strict 80-col layout preserved)
#0.2.1 - 'help tcl' warning scan extended to the has_libbug_* check family (bundled/vendored library bugs, e.g. the G-036 tcludp detection) alongside has_tclbug_*; buginfo 'url' key supported for reference links to non tcl-core trackers; fixed latent unset-indent error when a triggered check had a bugref/url but no description

1
src/modules/punk/AGENTS.md

@ -29,6 +29,7 @@ Source of truth for all modules under the `punk::*` namespace. This is the prima
- New modules under `punk::*` should be created as `<subpath>/<modulename>-999999.0a1.0.tm` following the namespace-to-path convention.
- punk::repl supports launch-time console selection (G-001): `repl::init -console <spec>` (any `punk::console::console_spec_resolve` spec form) selects the console the repl reads/writes; `repl::start`'s input channel argument is optional and defaults to the selected console's input. Repl output flows through per-repl channel state (`repl::conin/conout/conerr` - rputs maps stdout/stderr per-repl, conerr==conout for a foreign console pending G-011), the code interp's stdout/stderr are diverted via shellfilter `var` junction stacks and emitted to the console after each run (repltype punk/0 only so far), and eof/size/capability questions go through `repl::console_at_eof`/`repl::console_get_size` which dispatch to the selected `::opunk::Console` object's (possibly overridden) methods. Process-console behaviours (tcl_interactive prompt gating, stdin reopen on eof, raw-mode re-enable) apply only when no foreign console is selected. Tests: `src/tests/modules/punk/repl/testsuites/repl/consolebackends.test` (child-process drivers - a repl cannot run inside the shared testinterp; see the suite header).
- punk::repl has a dead-console watchdog (G-039): `repl::start` arms `repl::console_watchdog` (default 5s, read-only `chan configure -inputmode` probe) only for a tcl9 console input channel (-inputmode present) serving the process-default console on windows - the Tcl 9 console driver never delivers a dead console (killed conhost/terminal) to the script as a fileevent and its reader thread busy-loops on the persistent error, so without the watchdog an orphaned shell spins CPU forever. On probe failure the watchdog closes the input channel (stopping the driver's reader thread) and finishes the repl via the normal eof path. Piped, foreign-console and tcl 8.6 inputs never arm it. Root-cause and verification detail: `goals/archive/G-039-orphan-console-spin.md` (achieved 2026-07-12); upstream ticket FILED 2026-07-12: https://core.tcl-lang.org/tcl/tktview/f10d91c2d3 (full text with repro scripts in `TEMP_REFERENCE/tcl9-dead-console-spin-TICKET-DRAFT.md` - scripts elided from the web submission). If a future Tcl release fixes the driver, the watchdog can become version-conditional.
- punk::console uses the documented `-console` convention throughout: a `-console` value may be a 2-element {in out} channel list, an anchored `opunk::console` instance name, or an `::opunk::Console` object value (resolved via `punk::console::console_spec_resolve`). Query functions use the hybrid pattern (legacy trailing positional spec also accepted, parsed by `punk::console::internal::hybrid_console_spec`) - new query procs must follow it, with tests (see `src/tests/modules/punk/console/testsuites/console/queryprocs.test`). PUNKARGS definitions include the `-console` option via the shared fragments `::punk::console::argdoc::console_opts` (query/set functions) or `::punk::console::argdoc::console_emit_opts` (emit functions) rather than duplicating the option text; never re-add a `-minsize 2` constraint to `-console` (it rejects instance-name specs). The internal `get_size_using_*` size mechanisms deliberately remain canonical-pair positional (always fed by `get_size`).
- 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`.

887
src/modules/punk/args-999999.0a1.0.tm

File diff suppressed because it is too large Load Diff

13
src/modules/punk/args-buildversion.txt

@ -1,6 +1,17 @@
0.6.0
0.11.2
#First line must be a semantic version number
#all other lines are ignored.
#0.11.2 - bad-@dynamic warn-once + round-1 caching (user-reported: 'i join' emitted the "bad @dynamic tag" warning 4x, 'i join test' 6x): a @dynamic definition whose round-1 tstr output contains no round-2 parameters previously bypassed argdefcache_unresolved entirely, so EVERY resolve redid display masking plus the full round-1 tstr and re-warned - each 'i' invocation resolves several times (doc walk, advisory parse, get_spec for the render, synopsis), all cache hits for static definitions but full re-work for bad-dynamic ones. Such definitions are now cached as a zero-param unresolved entry: subsequent resolves take the cheap cached branch (consistent with the round-1 freezing legitimately dynamic definitions already get) and the warning emits once per definition per interp. Warning message corrected while there: @dynamic is NOT a complete no-op for a parse-inert definition - deferred display fields still re-expand per render instead of caching (expand_display_fields spec_dynamic gate), so the message now says removal is appropriate only if that display behaviour is unintended. Legitimately dynamic definitions unaffected (round-2 re-substitution pinned live). Also commented the dev diagnostics dump (puts of the full records list + ::testrecord global) on resolve's malformed-record error path - the raised message already carries the offending record; found when the malformed ::tcl_startOfNextWord tclcore definition triggered it (fixed in tclcore 0.3.2). New testsuite args/dynamic.test (warn-once + cached-parse pin, legit-dynamic round-2 freshness pin, stderr captured via channel transform).
#0.11.1 - documentation-only: define -help gains a 'Registration styles' section (direct define vs deferred lappend-PUNKARGS + register::NAMESPACES registration - lazy @id scan and on-demand definition via update_definitions, the module-template/moduledoc style, PUNKARGS_aliases, punk::args::status timings; prefer deferred for anything large) and an 'Interpolation (tstr placeholders) and the defspace' section (display-field deferral vs parse-field expansion at first resolve vs @dynamic re-expansion; defspace rule: direct define = calling namespace, registered PUNKARGS = the argdoc child namespace when it exists even if the PUNKARGS variable is in the parent; unresolvable placeholders left silently literal - check the defspace when a literal ${...} appears; robust patterns for load-time-computed values: explicit tstr pre-expansion, build-time string-map token, or setting the variable in argdoc). Motivated by the ::after id-shape harvest misattribution (2026-07-13, corrected record in goals/G-055).
#0.11.0 - G-041 increment 2 (doc-surface support): synopsis renderer honours the documented @form -synopsis override - the stored override now replaces the auto-calculated synopsis line in punk::args::synopsis full and summary renders (arg_error's synopsis section honoured it already; the per-form dict gains a 'synopsis' key when overridden, FORMARGS unchanged for dict consumers) - forms.test GAP forms_form_synopsis_override_stored_not_rendered_GAP flipped to forms_form_synopsis_override_rendered. Candidate-form ranking extended to choice discriminators: form_literal_affinity now treats a required argument with a RESTRICTED choice set as a discriminator (matched via choiceword_match, the shared G-040 resolver) alongside literal()/literalprefix() types - the tclcore models express subcommand-ish literals as -choices (e.g after's cancel/idle/info), so 'after cancel' now ranks the cancel forms first in noformmatch errors and parse_status best-candidate selection instead of falling back to declaration order.
#0.10.0 - G-041 multi-form candidacy: punk::args::parse/get_dict with the default -form * (or any multi-form selection) now attempts every permitted form instead of effectively parsing form 0 only - a clean match against exactly one form is auto-selected, no match raises a 'noformmatch' PUNKARGS VALIDATION error naming each candidate form's first-line failure (candidates ranked best-first: leading-literal affinity with the supplied words, then incomplete before invalid, then declaration order - private::form_selection/form_literal_affinity/rank_form_failures), and several clean matches raise 'multipleformmatches' naming the forms (no silent preference - deliberate, callers pass -form to disambiguate). -form now accepts a list of form names/indices as documented (get_dict, parse, parse_status, arg_error; supplied order preserved - arg_error renders the argument table for the first listed form and marks all listed forms' synopsis entries; parse's error render passes the ranked candidates). Parse results gain a 'form' key (the parsed form; keys appended after 'id' - positional consumers unaffected) and, when candidacy ran, a 'formstatus' key reporting every attempted form's outcome; parse_status 'form' is now the matched/best-candidate form (per-argument statuses and badarg marking built for it) and gains the documented 'formstatus' key (per-form status/failureclass/badarg/message with caller attribution) - the per-form compatibility surface the G-044 completion/hinting consumer needs. Engine body extracted verbatim to private::get_dict_form (single-form parse; argspecs resolved before selection - no caller-frame use). forms.test GAP pins flipped (auto-selection, -form list restriction) + new coverage (shared-prologue arity discrimination, noformmatch ranking/errorcode, multipleformmatches, formstatus in results and parse_status).
#0.9.0 - G-071: allocation choice screen - get_dict_can_assign_value no longer blanket-accepts any word for an argument with a RESTRICTED choice set; where allocation has an alternative (the argument is -optional, or a further occurrence of a -multiple argument) the candidate word is screened with choiceword_match (the shared G-040 implementation: exact/alias/prefix/nocase semantics; -choicemultiple words screened per list member within min/max), and a non-matching word yields onward to later elements/clauses instead of being consumed and failing validation. Fixes the lseq-class in-form failures: an optional choice noise word between required values plus a trailing optional-member clause ('lseq 0 10 2', '0 10 by 2', '1 5 by 0' now parse; noise-word variants and prefix normalization unchanged; genuinely invalid arglists now get the plain excess-values rejection instead of blaming the unrelated optional). REQUIRED arguments are deliberately not screened - the word must fill them and validation's choiceviolation reporting stays informative. -choicerestricted 0 behaviour unchanged. allocation.test GAPs flipped; parse_status -form documented-order behaviour pinned (a prior mis-premise: -form was already supported before the withid/withdef tail per its synopsis).
#0.8.3 - G-071: silenced four more unconditional debug puts on get_dict's allocation retreat paths ("get_dict cannot assign val:... (111)/(222)/(333)/(444)") - these fired on stderr during NORMAL successful parses whenever an optional element's candidate word was rejected and allocation retreated (e.g. every noise-word skip when parsing if/lseq-modelled commands), polluting interactive output. Same class as the 0.2.3/0.8.2 finds. The retreat logic they marked is the target of the G-071 allocator-correctness work; behaviour unchanged, output only. New allocation.test characterization suite (reduced lseq-range/if-shape fixtures): current mis-allocation and misblame pinned as GAP tests (flip with G-071), noise-word variants and if-shape guards pinned as regression guards, parse_status -form absence pinned as GAP.
#0.8.2 - fixed stray debug output: an unconditional 'puts' in private::get_dict_can_assign_value ("checking tp '<type>' against value '<value>'") fired on multi-element clause type checks (e.g. parsing 'try ... trap ... on ...' style arglists against the tclcore moduledoc), polluting interactive output - now commented like its 0.2.3 companion. Found during G-041 prework probing (real-vs-model divergence sweep of if/switch/try/lseq).
#0.8.1 - G-045 documentation-only: the container quoting rules pinned by defquoting.test promoted into the define -help documentation (-help key section): braced values fully literal (backslash sequences survive as typed) with only tstr placeholders and their backslash escape special; double-quoted values get Tcl backslash semantics at record parse (\n -> newline, doubled backslash collapses) while $ and [] stay literal with no variable/command substitution outside tstr placeholders; the backslash-escaped placeholder idiom renders a literal placeholder.
#0.8.0 - G-045: new bare @normalize directive - opts a definition into indent normalization of BLOCK-FORM multi-line field values (first line whitespace-only, as authored by opening a braced literal with a newline): the structural first newline and a whitespace-only trailing line are dropped, the content lines' common leading whitespace is the block's base indent, the first content line is unindented fully and subsequent lines re-based to the standard 4-space continuation convention (deeper relative indents preserved; whitespace-only inner lines become empty). Head-form values are never altered - their base indent is ambiguous (uniform continuation indent may be the deliberate +2 relative convention over base 4) - which makes @normalize a no-op on conforming file-style definitions (idempotence pinned). Fields in a record's -unindentedfields are exempt. Implemented as a resolve pre-pass over split records (private::normalize_records / rebase_multiline_value); @normalize with options is an error. Intended for constructed/string-built definitions (no whole-block indent treatment otherwise); ::punk::helptopic::define_docs converts to it as the consumer proof (punk 0.2.5). New testsuite normalize.test; define doc documents the directive; rendering.test P4 notes stay pinned as the unopted default.
#0.7.0 - G-045: record-continuation token -& - an unquoted trailing -& element on a definition record line continues the record on the next line. Implemented in private::split_definition_records: the token is dropped and the next line joins after a single space with its leading whitespace collapsed, exactly how the Tcl parser joins backslash-newline continuations before a braced definition reaches the splitter - so a -& record assembles byte-identical to its backslash-continued equivalent (proven by parse+render equality test). Motivation: constructed/string-built definitions cannot author backslash-newline ergonomically (the building code's own quoting consumes it); -& is plain text and survives any construction. Collision rules: the token must be a bare word preceded by whitespace (or the whole line), trailing whitespace after it is tolerated (more forgiving than raw backslash-newline); a braced/quoted -& is data ({-&} is the escape for a literal trailing value); -& mid-line, -& as a word suffix (abc-&), and -& on lines inside still-open braced/quoted values are all data. Backslash continuation authoring is unchanged (continuation is additive). New testsuite recordcontinuation.test; define doc documents the token alongside backslash continuation.
#0.6.1 - G-045: @cmd honours -unindentedfields for -help - arg_error's display-time indent transform (undent " "+help, max 4) is now gated by "-help" membership in the @cmd line's -unindentedfields list (same gate argument -help already had), so left-margin-authored cmd help renders its first line flush with continuations in both the table and string renderers. Previously the option was accepted on @cmd but ignored (rendering.test rendering_unindentedfields_cmd_help_GAP - flipped to rendering_unindentedfields_cmd_help). No in-tree definitions set @cmd -unindentedfields, so existing rendering is unchanged. Note: @cmd -summary has no indent transform in any renderer, so -unindentedfields membership for -summary is accepted and vacuously honoured. define doc for -unindentedfields now states where the option is valid.
#0.6.0 - synopsis display: small restricted choice sets now render as literal alternates - an argument whose choice pool (-choices plus -choicegroups members, deduplicated) has 1-3 members and -choicerestricted true (the default) displays those words unitalicised joined by | in synopses (e.g 'after cancel' shows literal cancel; a 3-choice option shows (left|centre|right)), matching the display style of literal()/literalprefix() type-alternatives. Larger or unrestricted (-choicerestricted 0) choice sets keep the italicised argname/<type> display, and an explicit -typesynopsis always takes precedence. New private helper punk::args::private::synopsis_choice_literals shared by both synopsis render paths (leaders/values via synopsis_form_arg_display, options inline in synopsis); applies only to single-element -type lists (multi-element clause display unchanged). define doc for -choices documents the rule. Tests: synopsis.test - new characterization coverage for literal/literalprefix/stringstartswith/stringendswith type-alternates, option alternate parenthesization, multi-element clause display, -typesynopsis (value element lists, option passthrough incl documenter ANSI), plus the new choice-literal rule (small sets in leader/option/value positions, choicegroups counting, >3 and unrestricted fallbacks, -typesynopsis precedence)
#0.5.0 - G-049 parse-status data model: new punk::args::parse_status - runs a parse attempt (withid/withdef) and returns a documented status structure instead of raising on validation failure (overall ok/status valid|invalid|incomplete/scheme/message/errorcode-minus-argspecs/failureclass/badarg/id/form/receivednames + per-argument argstatus with class/status ok|bad|unparsed/received/positions/hasvalue/value-in-effect incl -default fill). arg_error: new -parsestatus option - both renderers (table and string) now derive goodarg/badarg row marking and choice value-in-effect highlighting from the structure (built internally from -badarg/-parsedargs when not supplied), replacing the transient goodargs/badarg locals; scheme colours resolve per-render into a local array (scheme renders no longer mutate the shared arg_error_CLR array - the -nocolour leak) and the DOCUMENTED -scheme choice value 'nocolour' (and 'nocolor') now takes effect instead of falling through to leftover colours. parse: new -caller option overriding the %caller% frame-walk substitution in validation failure messages (parse_status defaults it to the definition's @cmd -name). get_dict: missingrequiredvalue/missingrequiredleader allocation failures now carry -badarg (type-failed words get badarg marking, not just choice violations). Tests: parsestatus.test (new), usagemarking.test nocolour/leak GAP pins flipped + -parsestatus render parity
#0.4.2 - G-046: display-field deferral - resolve no longer tstr-expands display-only content during argument resolution: ${...} in -help (@cmd/@examples/argument records) and @formdisplay -header/-body is masked with inert tokens (spec key DISPLAY_DEFERRED) and expanded on demand at display time (arg_error/eg/resolved_def/@default-copyfrom hooks; separate argdefcache_display cache for non-dynamic defs; @dynamic display content re-expands per render preserving provider refresh). First parse of heavily documented commands drops accordingly (punk::ansi::mark_columns ~4.3s -> ~12ms; tclcore ::lseq resolve ~184ms -> ~2ms) and -help content that calls punk::args-parsing commands (including against its own id) no longer stalls or loops - plus a display-time reentrancy guard (raw ${...} source substituted on nested expansion of the same id). Record splitter factored to private::split_definition_records. Also: @dynamic second-round multiline substitutions into deferred fields now get the 'line' paramindents alignment (rendering_atdynamic_multiline_help_insertion GAP flipped); prefix/alias choice normalization writeback no longer list-quotes single-element-clause values ({\Deleted} shape bug, choicegroups_imap_prefix_shape GAP flipped); -return string renderer aligns cmd-help continuations under the Description label and its Example line shows the example instead of the doc url. Tests: deferredhelp.test (new), rendering.test/choicegroups.test updated per G-046 acceptance

50
src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm

@ -257,7 +257,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
-summary\
"first start-of-word index after supplied index ${$I}start${$NI}"\
-help\
"Returns the index of the first start-of-word location that occurs after a starting index start
{Returns the index of the first start-of-word location that occurs after a starting index start
in the string str. A start-of-word location is defined to be the first word character following a
non-word character. Returns -1 if there are no more start-of-word locations after the starting point.
@ -268,7 +268,7 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
set idx [tcl_startOfNextWord $theString $idx]} {
puts "Word start index: $idx"
}
"
}
@values -min 2 -max 2
str -type string
start -type indexexpression
@ -4527,6 +4527,31 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
############################################################################################################################################################
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
#the after-id shape is harvested from the RUNNING interpreter (G-054 technique;
#user-directed 2026-07-13, probe record in goals/G-055) rather than hard-coded:
#a scheduled command's id is "after#N" in all known releases (tclTimer.c
#"after#%d"), and the cancelid/info forms use it as their id discriminator so
#accept/reject parity follows the interpreter these docs load into.
#The probe is safe: create + immediately cancel - no event loop entry, no output,
#no lasting state.
#The harvested prefix is consumed via tstr ${$after_id_prefix} placeholders in the
#::after definition below. The variable MUST live in the argdoc namespace: that is
#the DEFSPACE registered PUNKARGS definitions resolve their placeholders in
#whenever an argdoc child exists - even when the PUNKARGS list itself is in the
#parent, as here - and an unresolvable param is left silently literal (see the
#'Interpolation and the defspace' section of the punk::args::define help).
namespace eval argdoc {
set after_id_prefix "after#"
if {![catch {after 999999 {}} _aip_id]} {
catch {after cancel $_aip_id}
if {[regexp {^(.+#)\d+$} $_aip_id -> _aip_prefix]} {
set after_id_prefix $_aip_prefix
}
}
unset -nocomplain _aip_id _aip_prefix
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
lappend PUNKARGS [list {
#test of @form
@id -id ::after
@ -4570,7 +4595,15 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@leaders -min 1 -max 1
cancel -choices {cancel}
@values -min 1 -max 1
id
#id typed by the harvested id shape: a word of any other shape belongs to the
#cancelscript form (real 'after cancel <non-id>' is a script-match no-op).
#An id-SHAPED word remains genuinely ambiguous with a script of the same text -
#real Tcl resolves that by id liveness at runtime (tries the id first, falls
#back to script match), which no static type expresses (goals/G-055 record).
id -type stringstartswith(${$after_id_prefix}) -typesynopsis id -help\
"Identifier of the delayed command to cancel.
It must have been the return value from a previous
after command (${$after_id_prefix}N shaped)."
#@form -form {cancelscript} -synopsis "after cancel script ?script...?"
@ -4593,7 +4626,13 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
@leaders -min 1 -max 1
info -choices {info} -choiceprefixreservelist {idle}
@values -min 0 -max 1
id -optional 1
#real 'after info <non-id-shaped-word>' errors at runtime ("event ... doesn't
#exist") - the model's shape rejection keeps error-vs-ok parity
id -optional 1 -type stringstartswith(${$after_id_prefix}) -typesynopsis id -help\
"Identifier of an existing event handler - the
return value from some previous call to after
(${$after_id_prefix}N shaped). It must not have triggered
yet or been canceled."
} "@doc -name Manpage: -url [manpage_tcl after]"\
{
@ -4848,7 +4887,6 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
return [punk::args::ensemble_subcommands_definition -groupdict $groups -columns 2 array]
}
lappend PUNKARGS [list {
@dynamic
@id -id ::array
@cmd -name "Built-in: array"\
-summary\
@ -6463,7 +6501,6 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
lappend PUNKARGS [list {
@dynamic
@id -id ::join
@cmd -name "Built-in: join"\
-summary\
@ -9299,7 +9336,6 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
}]}
}
punk::args::define {
@dynamic
@id -id ::split
@cmd -name "Built-in: split"\
-summary\

6
src/modules/punk/args/moduledoc/tclcore-buildversion.txt

@ -1,4 +1,8 @@
0.2.0
0.3.3
#First line must be a semantic version number
#all other lines are ignored.
#0.3.3 - removed the parse-inert @dynamic tags from ::split, ::array and ::join (user-directed; identified by the 0.3.2 sweep). None has round-2 substitution content, and their ${...} placeholders are all display-field styling/examples - the definitions now resolve as ordinary static definitions with display expansion cached per raw definition (previously @dynamic forced display re-expansion on every render). No bad-@dynamic warnings remain across the module's 462 registered ids; renders and parses verified unchanged for all three.
#0.3.2 - fixed malformed ::tcl_startOfNextWord definition: its -help was double-quoted but the embedded man-page example contains inner double quotes (set theString "The quick brown fox" / puts "Word start index: ..."), so the value terminated early and the definition failed to resolve at all (punk::args::resolve 'bad optionspecs line' error; 'i tcl_startOfNextWord' broken). The -help is now braced (fully literal per the define quoting rules - inner quotes and brackets safe, text verbatim, example braces balance). Found by a bad-@dynamic sweep across all 462 registered ids (punk::args 0.11.2 work); the same sweep identified the parse-inert @dynamic tags on ::split, ::array and ::join (each now warns once per interp) - left in place pending a decision on their display-refresh semantics (@dynamic still forces display-field re-expansion per render).
#0.3.1 - authoring-style only (user-directed): the ::after id-shape harvest is consumed via tstr ${$after_id_prefix} placeholders instead of the 0.3.0 build-time %AFTERIDPREFIX% string map - the harvest variable now lives in the argdoc namespace (the defspace registered PUNKARGS definitions resolve placeholders in when an argdoc child exists; see the punk::args::define 'Interpolation and the defspace' help section), which makes plain placeholders work in both the -type parse field (expanded at first resolve) and the -help display fields (expanded at display time). This module showcases the tstr style; string map remains reserved for cases where build-time substitution is genuinely necessary. Behaviour identical to 0.3.0 (resolved -type, form discrimination, parity pins and help renders re-verified).
#0.3.0 - ::after cancel-id discrimination (user-directed 2026-07-13): the cancelid and info forms' id argument is typed stringstartswith(<prefix>) with the prefix harvested from the RUNNING interpreter at define time (safe create+cancel probe 'after 999999 {}' / 'after cancel $id' - G-054 technique; prefix is after# on 8.6.11 and 9.0.3, hardcoded after#%d in tclTimer.c), substituted into the definition via a build-time %AFTERIDPREFIX% string map (CORRECTED finding: parse-field tstr IS expanded for registered PUNKARGS definitions, but in the argdoc subnamespace when one exists - a variable set in the parent namespace is unresolvable there and the param is left silently literal; build-time substitution sidesteps the defspace subtlety). -typesynopsis id keeps the synopsis rendering as the man page's 'id'. Effect under G-041 form candidacy: 'after cancel <non-id-shaped-word>' resolves cleanly to the cancelscript form matching real semantics (real 'after cancel' with a non-id is a silent script-match no-op), and 'after info <non-id>' is model-rejected where real errors at runtime (parity-true); an id-SHAPED word after cancel remains truthfully ambiguous (cancelid+cancelscript) - real Tcl resolves that junction by id liveness at runtime, which no static type expresses; dead-id over-acceptance on 'after info' recorded as the accepted runtime-liveness boundary. Both ids gain man-page-derived -help text. Parity pins added in tclcoreparity.test (id-shape harvest agreement, cancel discrimination incl the liveness ambiguity, info error-vs-ok parity + accepted dead-id divergence).
#0.2.0 - G-054: 'string is' class choices (and the generated per-class virtual docids) are harvested from the RUNNING interpreter at define time instead of a hand-maintained list - a deliberately invalid probe of the builtin yields the authoritative class set from its error message (8.6: 21 classes, no dict; unreleased 8.7: +dict +unicode; 9.0: +dict, unicode removed), fixing accept/reject drift such as the doc wrongly accepting 'string is dict' under 8.6. Hand-written man-page descriptions apply only to classes the runtime accepts (generic label for unrecognized future classes); static version notes on dict (not in 8.6) and unicode (unreleased 8.7 only). Parity pinned by tclcoreparity.test with expectations derived from the live interpreter (green on 8.6.13, 8.7a6, 9.0.3)

12
src/modules/punk/lib-999999.0a1.0.tm

@ -2455,8 +2455,11 @@ namespace eval punk::lib {
#no change in origin - so we are into the arguments of the command, or have an invalid subcommand - stop looking for subcommands
#todo - detect invalid subcommand and count as unknown command?
break
} elseif {[dict get $test_cinfo cmdtype] in {"proc" "native" "notfound"}} {
} elseif {[dict get $test_cinfo cmdtype] in {"proc" "native" "notfound" "doconly"}} {
#we have a subcommand that won't be introspectable at a deeper level.
#(doconly: G-051 - cmdinfo's truthful cmdtype for documentation-only
#levels such as 'string is xdigit', previously reported 'notfound';
#treated identically here to preserve prior analysis behaviour)
set cinfo $test_cinfo
set ctype [dict get $cinfo cmdtype]
break
@ -2714,7 +2717,12 @@ namespace eval punk::lib {
dict lappend resultd commands_native $dispatchwords
}
}
"notfound" {
"notfound" -
"doconly" {
#doconly: G-051 - documentation-only level (e.g 'string is xdigit'),
#previously reported as 'notfound'; bucketed identically to preserve
#prior analysis behaviour (a future refinement may count doconly as a
#valid command word rather than notfound)
if {$dispatchwords ni [dict get $resultd commands_notfound]} {
dict lappend resultd commands_notfound $dispatchwords
}

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

@ -1,6 +1,7 @@
0.4.0
0.4.1
#First line must be a semantic version number
#all other lines are ignored.
#0.4.1 - G-051 call-site adaptation: tclscript analysis accepts cmdinfo's new 'doconly' cmdtype alongside 'notfound' (subcommand-walk stop condition and the dispatchwords bucketing switch) - bucketed identically to notfound to preserve prior analysis behaviour; a future refinement may count doconly (a documented pseudo-command such as 'string is xdigit') as a valid command word.
#0.4.0 - G-058: snapshot_package_paths and interp_sync_package_paths now also propagate the runtime static/builtin package baseline captured at kit boot (::punkboot::static_prefixes/static_packages - see punk_main.tcl) and seed 'package ifneeded <name> <ver> {load {} <prefix>}' entries in the target thread/interp, so statically-linked runtime packages (e.g Thread/twapi on a tcl-sfe runtime) resolve in fabricated interps whose package search paths punkshell controls. No-op when no baseline was captured (plain tclsh dev launches).
#0.3.1 - Tcl 9.1 compatibility (TIP 746 removed expr behaviour from lseq operands): range (lseq branch) now normalizes int[+-]int offset operands itself via offset_expr - callers such as `range 0 [llength $list]-1` keep working; also aligned the lseq branch with the fallback contract: default by now infers direction (range 5 1 -> descending, previously empty under tcl9) and by 0 returns empty (Tcl 9.1 lseq changed by-0 to return one element). lzipn_tcl9b/lzipn_tcl9c/cols/cols2: lseq expression operands wrapped in expr. check::has_tclbug_safeinterp_compile: Tcl 9.1 safe interps hide tcl::unsupported::* - falls back to interp invokehidden tcl:unsupported:disassemble
#0.3.0 - new: punk::lib::check::has_libbug_udp_threadexit + libbug_udp_threadexit_applies classifier - version-based detection of tcludp < 1.0.13 on Tcl 9 Windows (per-thread exit handler closes process-global event handles; G-036). has_libbug_* is the new check family for bundled/vendored library bugs, surfaced through 'help tcl' alongside has_tclbug_*; buginfo dicts may carry a full 'url' reference key (non tcl-core trackers)

158
src/modules/punk/ns-999999.0a1.0.tm

@ -4845,6 +4845,15 @@ y" {return quirkykeyscript}
return [cmdinfo {*}$next]
}
}
if {$cmdtype eq "notfound" && $docid ne ""} {
#G-051: resolution landed on a punk::args id with no corresponding real
#command - a documentation-only level below (or beside) a real command,
#e.g the per-class id "::tcl::string::is true" documenting a level below
#the real ::tcl::string::is (the class words are arguments, not
#subcommands). Report it distinctly so consumers can tell it from a
#genuinely unknown command. The docid remains authoritative for display.
set cmdtype doconly
}
return [list origin $origin cmdtype $cmdtype args_resolved [list [lindex $commands 0] {*}$consumed_args] args_remaining $remainingargs docid $docid stack $stack]
}
proc cmd_traverse {ns formid args} {
@ -5023,8 +5032,49 @@ y" {return quirkykeyscript}
#(would not support shor-form prefix of subcommand - even if the proc implementation did)
set docid_exists 0
set eparams [list]
set a_spaceform ""
if {[punk::args::id_exists "$origin [lindex $args $i]"]} {
set a [lindex $args $i]
set a_spaceform [lindex $args $i]
} elseif {$docid ne "" && [punk::args::id_exists $docid]} {
#G-051 space-form docid prefix parity: no space-form id exists for the
#exact word - if the current level's definition has a choices-bearing
#first leader, resolve the word with the same shared resolver argument
#parsing uses (punk::args::choiceword_match - honouring -choiceprefix,
#-nocase, -choicealiases, -choiceprefixdenylist,
#-choiceprefixreservelist) and retry the space-form lookup with the
#canonical word - so 'i string is tr' lands on the documentation for
#what 'string is tr' actually executes. No second matching rule: a word
#parse would reject resolves nothing here either, and a canonical with
#no space-form id falls through to the normal per-level handling.
set pf_spec [punk::args::get_spec $docid]
set pf_fid [lindex [dict get $pf_spec form_names] 0]
set pf_leaders [dict get $pf_spec FORMS $pf_fid LEADER_NAMES]
if {[llength $pf_leaders]} {
set pf_arginfo [dict get $pf_spec FORMS $pf_fid ARG_INFO [lindex $pf_leaders 0]]
set pf_allchoices [punk::args::system::Dict_getdef $pf_arginfo -choices {}]
foreach {_pf_g pf_members} [punk::args::system::Dict_getdef $pf_arginfo -choicegroups {}] {
lappend pf_allchoices {*}$pf_members
}
if {[llength $pf_allchoices]} {
set pf_matchinfo [punk::args::choiceword_match [lindex $args $i]\
[punk::args::system::Dict_getdef $pf_arginfo -nocase 0]\
$pf_allchoices\
[punk::args::system::Dict_getdef $pf_arginfo -choicealiases {}]\
[punk::args::system::Dict_getdef $pf_arginfo -choiceprefix 1]\
[punk::args::system::Dict_getdef $pf_arginfo -choiceprefixdenylist {}]\
[punk::args::system::Dict_getdef $pf_arginfo -choiceprefixreservelist {}]\
]
if {[dict get $pf_matchinfo matched]} {
set pf_canonical [dict get $pf_matchinfo canonical]
if {$pf_canonical ne [lindex $args $i] && [punk::args::id_exists "$origin $pf_canonical"]} {
set a_spaceform $pf_canonical
}
}
}
}
}
if {$a_spaceform ne ""} {
set a $a_spaceform
#review - tests?
#puts stderr "cmd_traverse - skipping to documented subcommand '$origin $a'"
#we can only seek beyond an undocumented subcommand level via a space delimited path, as we can make no assumption about the actual location of a subcommand relative to its parent
@ -5034,7 +5084,7 @@ y" {return quirkykeyscript}
set origin [list $origin $a]
incr i
set queryargs [lrange $args $i end]
set resolvedargs [list $a] ;#
set resolvedargs [list $a] ;#the canonical word (a resolved prefix/alias records its canonical, as parse normalization does)
set queryargs_untested $queryargs
} elseif {[punk::args::id_exists $docid]} {
set docid_exists 1
@ -5374,6 +5424,10 @@ y" {return quirkykeyscript}
on separate lines.
If -form formname|<int> is given, supply only
the synopsis for that form.
For a multiform command, trailing argument words
after the command path underline the form(s) they
match - the best candidate when no form fully
matches (G-041 multi-form candidacy).
"
@opts
-form -type number|name -default * -help\
@ -5422,10 +5476,38 @@ y" {return quirkykeyscript}
return
}
#G-041: for a multiform definition with trailing argument words and no explicit
#-form restriction, determine the form(s) the words match via the advisory parse
#(punk::args::parse_status multi-form candidacy) - the matching/best-candidate
#form's synopsis line is underlined below (e.g 's after cancel someid' marks the
#cancel form). Skipped when alias currying makes the remaining words unreliable.
set markforms [list]
set docid_forms [list]
if {$form eq "*" && !$excess && [llength $unresolved_args] && $doc_id ne ""} {
catch {set docid_forms [punk::args::forms $doc_id]}
if {[llength $docid_forms] > 1} {
if {![catch {punk::args::parse_status $unresolved_args withid $doc_id} pstat]} {
dict for {fname finfo} [dict get $pstat formstatus] {
if {[dict get $finfo status] eq "valid"} {
lappend markforms $fname
}
}
if {![llength $markforms]} {
#no form fully matches - mark the best candidate
set markforms [list [dict get $pstat form]]
}
}
}
}
#when we use list operations on $syn - it can get extra braces due to ANSI - use join to bring back to a string without extraneous bracing
switch -- $opt_return {
full - summary {
set resultstr ""
#ordinal position of each non-comment line maps to the definition's form
#(declaration order - both the full and summary renders emit one synopsis
#line per form in that order)
set formidx 0
foreach synline [split $syn \n] {
if {[string range $synline 0 1] in {"# " "##"}} {
append resultstr $synline \n
@ -5454,8 +5536,14 @@ y" {return quirkykeyscript}
append lineout " " [list $part]
}
}
set lineout [string trim $lineout]
#G-041: underline the form(s) the supplied trailing words match
if {[llength $markforms] && [lindex $docid_forms $formidx] in $markforms} {
set lineout "[punk::ansi::a+ underline]$lineout[punk::ansi::a+ nounderline]"
}
incr formidx
#must be no leading space for tests in test::punk::args synopsis.test
append resultstr [string trim $lineout] \n
append resultstr $lineout \n
}
}
@ -5520,8 +5608,13 @@ y" {return quirkykeyscript}
} {${[punk::args::resolved_def -types opts ::punk::args::arg_error -scheme]}} {
-form -default 0 -help\
"Ordinal index or name of command form"
-form -default * -help\
"Restrict to the listed command forms - each element an
ordinal index or form name (see punk::args::parse -form).
With the default * the usage display presents the form
best matching any supplied argument words (G-041
multi-form candidacy) - e.g 'i after cancel <id>'
presents the cancel form."
-grepstr -default "" -type list -typesynopsis regex -help\
"Case insensitive grep for pattern in the output.
list consisting of regex, optionally followed by ANSI names for highlighting"
@ -5605,15 +5698,24 @@ y" {return quirkykeyscript}
#-caller attributes any failure message to the queried command rather than
#an internal parse call site.
set pstatus [punk::args::parse_status $queryargs -form $opt_form -caller $querycommand withid $rootdoc]
#With NO supplied args a failure only reflects missing required
#leaders/values - nothing to mark and nothing wrong with the user's (absent)
#input, so we show plain usage in the info scheme instead of the
#internal-looking parse error (G-046 item 5).
#A failing advisory parse renders with the error scheme and its
#message even when NO args were supplied - see the matching site in
#the main cmdhelp body for the history (G-046 item 5 suppression
#reversed 2026-07-12 after the G-049 -caller attribution made the
#messages accurate).
#G-041: render the usage for the form the advisory parse selected
#(the matched or best-candidate form within the caller's -form
#selection); for a no-form-match pass every ranked candidate and for
#an ambiguous match every matching form - the first form's argument
#table renders and all passed forms are marked in the synopsis.
if {![dict get $pstatus ok] && [dict get $pstatus failureclass] in {noformmatch multipleformmatches}} {
dict set nextopts -form [dict keys [dict get $pstatus formstatus]]
} else {
dict set nextopts -form [dict get $pstatus form]
}
if {$opt_return eq "dict"} {
if {$scheme_received} {
dict set pstatus scheme [dict get $opts -scheme]
} elseif {![dict get $pstatus ok] && ![llength $queryargs]} {
dict set pstatus scheme info
}
return [dict create origin $rootorigin docid $rootdoc cmdtype $rootorigintype args_remaining $queryargs parsestatus $pstatus]
}
@ -5623,11 +5725,6 @@ y" {return quirkykeyscript}
dict set nextopts -scheme info
}
set result [punk::args::arg_error "" [punk::args::get_spec $rootdoc] {*}$nextopts -aserror 0 -parsestatus $pstatus]
} elseif {![llength $queryargs]} {
if {!$scheme_received} {
dict set nextopts -scheme info
}
set result [punk::args::arg_error "" [punk::args::get_spec $rootdoc] {*}$nextopts -aserror 0]
} else {
set result [punk::args::arg_error [dict get $pstatus message] [punk::args::get_spec $rootdoc] {*}$nextopts -aserror 0 -parsestatus $pstatus]
}
@ -5712,15 +5809,27 @@ y" {return quirkykeyscript}
#-errorstyle minimal so no usage table is built inside a discarded error and
#dynamically updated ensembles are reflected).
set pstatus [punk::args::parse_status $args_remaining -form $opt_form -caller $caller_display withid $origindoc]
#With NO supplied trailing args a failure only reflects missing required
#leaders/values (e.g 'i string is') - nothing to mark and nothing wrong with the
#user's (absent) input, so we show plain usage in the info scheme instead of the
#internal-looking parse error (G-046 item 5).
#A failing advisory parse renders with the error scheme and its message even
#when NO trailing args were supplied: 'i if' indicating "Bad number of
#trailing values for if. Got 0 values. Expected at least 2" is useful signal
#that the command cannot be called bare. (History: G-046 item 5 suppressed
#this path because the pre-G-049 message was internal-looking - "for
#punk::args::parse $args_remaining" - but the G-049 -caller attribution made
#the messages accurate, so the suppression was reversed 2026-07-12 by user
#direction, restoring the pre-G-046 indication with the improved messages.)
#G-041: render the usage for the form the advisory parse selected (the
#matched or best-candidate form within the caller's -form selection); for a
#no-form-match pass every ranked candidate and for an ambiguous match every
#matching form - the first form's argument table renders and all passed
#forms are marked in the synopsis.
if {![dict get $pstatus ok] && [dict get $pstatus failureclass] in {noformmatch multipleformmatches}} {
dict set nextopts -form [dict keys [dict get $pstatus formstatus]]
} else {
dict set nextopts -form [dict get $pstatus form]
}
if {$opt_return eq "dict"} {
if {$scheme_received} {
dict set pstatus scheme [dict get $opts -scheme]
} elseif {![dict get $pstatus ok] && ![llength $args_remaining]} {
dict set pstatus scheme info
}
return [dict create origin $origin docid $origindoc cmdtype $origintype args_remaining $args_remaining parsestatus $pstatus]
}
@ -5730,11 +5839,6 @@ y" {return quirkykeyscript}
dict set nextopts -scheme info
}
set result [punk::args::arg_error "" [punk::args::get_spec $origindoc] {*}$nextopts -aserror 0 -parsestatus $pstatus]
} elseif {![llength $args_remaining]} {
if {!$scheme_received} {
dict set nextopts -scheme info
}
set result [punk::args::arg_error "" [punk::args::get_spec $origindoc] {*}$nextopts -aserror 0]
} else {
set result [punk::args::arg_error [dict get $pstatus message] [punk::args::get_spec $origindoc] {*}$nextopts -aserror 0 -parsestatus $pstatus]
}

5
src/modules/punk/ns-buildversion.txt

@ -1,6 +1,9 @@
0.2.0
0.5.0
#First line must be a semantic version number
#all other lines are ignored.
#0.5.0 - G-041 doc surface: cmdhelp's -form option defaults to * (was 0) and accepts the punk::args::parse list semantics - the usage display now presents the form the supplied argument words match: the advisory parse_status's matched/best-candidate form's argument table renders at both render sites (alias path and main), with every ranked candidate (noformmatch) or every matching form (multipleformmatches) passed to arg_error so all are marked in the synopsis block ('i after cancel <id>' presents the cancel form; 'i lseq 0 10 2' presents the range form). punk::ns::synopsis: with trailing argument words after a multiform command path (and no explicit -form), the form(s) the words match are underlined - matching forms from the advisory parse's formstatus, or the best candidate when no form fully matches ('s after cancel someid' marks both cancel forms; 's lseq 0 10 2' marks the range form); ordinal non-comment-line position maps lines to forms in declaration order for both the full and summary renders; skipped when alias currying makes the remaining words unreliable. cmdhelp.test gains the multiform doc-surface pins (autoselected form presented, noformmatch best-candidate table + candidate naming, synopsis marking present/absent).
#0.4.0 - G-051: (a) cmdinfo reports cmdtype 'doconly' (was 'notfound') when resolution lands on a punk::args id with no corresponding real command - documentation-only levels such as the per-class id "::tcl::string::is true" below the real ::tcl::string::is, and TclOO documented-method docids like "<class> docmeth" (method case adopts doconly; may be refined by G-052). Consumers audited: cmdhelp/synopsis/eg are docid-driven, cmdtrace only tests for 'proc', punk::lib script analysis updated in step (lib 0.4.1). (b) space-form docid prefix parity: cmd_traverse's space-delimited child docid jump, on an exact-word miss, resolves the word against the current level's choices-bearing first leader via punk::args::choiceword_match (the shared G-040 resolver - -choiceprefix/-nocase/-choicealiases/denylist/reservelist honoured) and retries with the canonical word - so 'i string is tr' resolves to "::tcl::string::is true" exactly when 'string is tr' executes, ambiguous/unknown/denied words stay at the parent exactly when parse rejects them, and resolvedargs records the canonical (as parse normalization does). Tests: the four G-051 GAP pins flipped (cmdhelp_pseudo_command_cmdtype_doconly, cmdhelp_spaceform_docid_prefix_honoured incl ambiguous/unknown guard, cmdhelp_string_is_true_pseudo_doconly, cmdhelp_string_is_prefix_honoured).
#0.3.0 - reversal of the G-046-item-5 no-supplied-words suppression (user direction 2026-07-12): cmdhelp's advisory parse failing with NO supplied argument words renders the failure message and error scheme again (both the alias path and the main path; dict returns no longer rewrite the scheme to info), so 'i if'/'i while'/'i foreach' once more signal that the command cannot be called bare - e.g "Bad number of trailing values for if. Got 0 values. Expected at least 2". Rationale: the suppression (ns 0.1.4) existed because the pre-G-049 message was internal-looking ("... for punk::args::parse $args_remaining ..."); the G-049 -caller attribution (ns 0.2.0) made bare-query messages accurate - including the original 'i string is' complaint case, which now reads "Bad number of leading values for string is. Got 0 leaders. Expected exactly 1". Tests: cmdhelp_leader_required_no_args_plain_usage flipped to cmdhelp_leader_required_no_args_error_render; cmdhelp_return_dict_scheme expects scheme error for the bare-query failure.
#0.2.0 - G-049: cmdhelp -return dict - machine-parsable return carrying resolution info (origin/docid/cmdtype/args_remaining) plus the parse-status structure of the supplied argument words (punk::args::parse_status shape; empty for undocumented commands); the scheme field reflects an explicit -scheme and the G-046-item-5 no-supplied-words suppression. cmdhelp's advisory parse now runs via punk::args::parse_status on both the alias path and the main path: an explicit -scheme is honoured on the parse-failure render (previously only on success/tableobject - failures returned parse's internally rendered error with the default error scheme), failure renders consume the structure via arg_error -parsestatus (badarg marking now covers type/allocation failures via the structure), and the failure message names the queried command (parse -caller: querycommand + consumed subcommand words) instead of whatever the %caller% frame walk found - at top call depth that was cmdhelp's own raw 'punk::args::parse $args_remaining ...' source text. Tests: cmdhelp.test G-049 GAP pins flipped + cmdhelp_return_dict_* added
#0.1.4 - G-046 item 5: cmdhelp's advisory goodargs parse failing with NO supplied argument words (e.g 'i string is' where the definition requires leaders) now shows plain info-scheme usage instead of the internal-looking "Bad number of leading values for punk::args::parse ..." error output (both the alias path and the main path; error display for supplied-but-invalid words unchanged). The no-supplied-words advisory parse runs with -errorstyle minimal so its failure doesn't render the full usage table inside the discarded error - large argdocs (e.g 'i punk::args::define') render the table once, not twice (verified parity with pre-G-046 timings: ~5.3s first/~4.1s repeat on punk91 src, table construction dominant). Test: cmdhelp.test cmdhelp_leader_required_no_args_plain_usage
#0.1.3 - documentation-only: cmdhelp 'subcommand' argument help rewritten to match actual behaviour (was described as ensemble-subcommands-only; also covers tcl::oo methods and argument words, whose validity drives the info/error scheme and received-argument marking of the usage display)

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

@ -629,12 +629,36 @@ proc repl::start {args} {
#catch {
# set punk::console::tabwidth [punk::console::get_tabstop_apparent_width]
#}
#dead-console watchdog (G-039): the Tcl 9 windows console driver never delivers a dead
#console (killed conhost/terminal) to the script level as a fileevent, and its reader
#thread busy-loops on the persistent error - an orphaned shell would spin CPU forever.
#Poll liveness on the process console so the repl can finish via the normal eof path.
#Armed only for a tcl9 console channel (-inputmode present) on the process-default
#console; piped/foreign/8.6 inputs are unaffected.
variable console_watchdog_afterids
variable console_watchdog_ms
set watchdog_chan ""
if {"windows" eq $::tcl_platform(platform) && [console_is_default]
&& ![info exists console_watchdog_afterids($inchan)]} {
if {![catch {chan configure $inchan} wdconf] && [dict exists $wdconf -inputmode]} {
set watchdog_chan $inchan
set console_watchdog_afterids($inchan) [after $console_watchdog_ms [list [namespace current]::console_watchdog $inchan]]
}
}
vwait [namespace current]::done
if {$watchdog_chan ne "" && [info exists console_watchdog_afterids($watchdog_chan)]} {
after cancel $console_watchdog_afterids($watchdog_chan)
array unset console_watchdog_afterids $watchdog_chan
}
#done can be set before the deferred reader registration above has run (e.g exit/quit message from codethread arriving first)
#in which case the pending idle callback would re-register a readable handler on a repl that has finished
# - it must be cancelled as well as clearing any active registration.
after cancel $reader_registration_id
chan event $inchan readable {}
if {$inchan in [chan names]} {
#the console_watchdog closes a dead-console inchan before setting done
chan event $inchan readable {}
}
#puts stderr "-->start done = $::repl::done"
@ -933,6 +957,63 @@ proc repl::console_at_eof {inputchan} {
return [chan eof $inputchan]
}
namespace eval repl {
#dead-console watchdog state (G-039) - see repl::console_watchdog
variable console_watchdog_ms 5000
variable console_watchdog_afterids
array set console_watchdog_afterids {}
}
namespace eval repl::argdoc {
lappend PUNKARGS [list {
@id -id ::repl::console_watchdog
@cmd -name "repl::console_watchdog"\
-summary\
"Self-rescheduling liveness poll for the repl's process-console input channel."\
-help\
"Probes the repl input channel's console with 'chan configure -inputmode'
(a live GetConsoleMode call on windows) every repl::console_watchdog_ms
milliseconds. If the probe fails the hosting console is gone (e.g the
terminal or conhost was killed): the Tcl 9 windows console driver never
delivers that state to the script level as a fileevent (its ConsoleEventProc
only notifies on buffered data) and its reader thread busy-loops on the
persistent channel error, so an orphaned shell would otherwise spin CPU
indefinitely (G-039). On a failed probe the watchdog closes the input
channel (which lets the driver's reader thread exit, stopping the spin)
and finishes the repl via the normal eof done-path.
Armed by repl::start only for a tcl9 console channel (-inputmode present
in the chan configure dict) serving the process-default console on
windows. Scheduling state is kept per channel name in
repl::console_watchdog_afterids; a watchdog whose channel has
disappeared disarms itself silently."
@values -min 1 -max 1
inputchan -type string -help\
"the repl input channel being watched"
}]
}
proc repl::console_watchdog {inputchan} {
variable console_watchdog_afterids
variable console_watchdog_ms
if {$inputchan ni [chan names]} {
array unset console_watchdog_afterids $inputchan
return
}
if {[catch {chan configure $inputchan -inputmode}]} {
#GetConsoleMode failed - the hosting console is gone.
#Close the channel (stops the tclWinConsole.c reader thread's error busy-loop)
#and finish the repl via the normal eof path. Do not attempt a console reopen:
#with the console dead, CONIN$ cannot be opened either (app-punkshell's eof
#handling makes the same discovery and exits cleanly).
array unset console_watchdog_afterids $inputchan
catch {chan event $inputchan readable {}}
catch {chan close $inputchan}
catch {puts stderr "|repl> console_watchdog: console unavailable for '$inputchan' - closing input and finishing repl (eof)"}
set ::repl::done [list eof $inputchan]
return
}
set console_watchdog_afterids($inputchan) [after $console_watchdog_ms [list [namespace current]::console_watchdog $inputchan]]
}
namespace eval repl::argdoc {
lappend PUNKARGS [list {
@id -id ::repl::console_get_size

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

@ -1,6 +1,7 @@
0.4.0
0.5.0
#First line must be a semantic version number
#all other lines are ignored.
#0.5.0 - G-039: new repl::console_watchdog - a self-rescheduling liveness poll (default 5s, repl::console_watchdog_ms) armed by repl::start for a tcl9 console input channel (-inputmode present) serving the process-default console on windows. The Tcl 9 windows console driver never delivers a dead console (killed conhost/terminal) to the script level as a fileevent (tclWinConsole.c ConsoleEventProc only notifies on buffered data) and its reader thread busy-loops on the persistent channel error, so an orphaned shell previously spun ~2 cores indefinitely. On a failed probe (chan configure -inputmode = live GetConsoleMode) the watchdog closes the input channel (stopping the driver reader thread) and finishes the repl via the normal eof done-path; app-punkshell's eof handling then finds no console reopenable and exits cleanly. repl::start's post-vwait reader-deregistration now tolerates an inchan closed by the watchdog. Piped/foreign-console/tcl8.6 inputs are unaffected (watchdog not armed).
#0.4.0 - G-001: repl::init -console <spec> selects the console the repl reads/writes ({in out} pair, anchored opunk::console instance name, or ::opunk::Console object value, resolved via punk::console::console_spec_resolve). repl::start's inchan is now optional (defaults to the selected console's input). New repl-level channel state (conin/conout/conerr) is routed through rputs (stdout/stderr mapped per-repl) and doprompt; for a selected foreign console the code interp's stdout/stderr are diverted via shellfilter 'var' junction stacks and emitted to the console after each run. New helpers repl::console_is_default / console_at_eof / console_get_size - eof and size are answered by the selected console object's (possibly overridden) methods. Process-console behaviours (tcl_interactive prompt gating, stdin reopen on eof, raw-mode re-enable, utf-16be windows line re-decode experiment, mode-line on exit) now apply only to the default console. Default-console (stdin/stdout) behaviour unchanged. Also fixes rputs pseudo-channel mapping in the 3-arg -nonewline form (mapped value previously written over the -nonewline flag).
#0.3.0 - G-058: codethread init script receives the runtime static/builtin package baseline (::punkboot::static_prefixes/static_packages via new %staticprefixes%/%staticpackages% scriptmap entries) and seeds ifneeded mappings in the codethread interp, so the code interp (seeded in turn via punk::lib::interp_sync_package_paths) can package-require statically-linked runtime packages - fixes 'can't find package Thread' booting a punk9win.vfs kit on the tclsfe-x64 static runtime (punk91)
#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.

162
src/tests/modules/punk/args/testsuites/args/allocation.test

@ -0,0 +1,162 @@
package require tcltest
package require punk::args
#Value-allocation characterization for optional standalone values and
#optional-member clauses (G-071) - added 2026-07-12 before any allocator changes
#(tests-first per the punk::args convention). Reduced fixtures isolate the
#lseq-range shape (start ?sep? end ?by-step clause?) and the if shape (noise-word
#clauses) from the tclcore moduledoc, so the pins do not depend on moduledoc
#availability or vintage.
#
#History: added with GAP pins encoding the pre-G-071 broken allocation (the
#allocator blanket-accepted any word for a restricted-choice value at allocation
#time, so the optional 'sep' noise word greedily consumed the word after start
#and '1 2 3' / '1 2 by 3' failed in-form with a trailing-choices error).
#G-071 added a choiceword_match allocation screen for restricted choice sets
#(punk::args 0.9.0): a non-choice word is no longer allocatable to 'sep', so it
#yields onward to end and the by-step clause - the GAPs below flipped to the
#correct outcomes the same day they were added.
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
punk::args::define {
@id -id ::testspace::alloc_range
@values -min 2 -max 5
start -type int
sep -type string -choices {.. to} -optional 1
end -type int
"by step" -type {?literalprefix(by)? int} -optional 1
}
punk::args::define {
@id -id ::testspace::alloc_if
@values -min 2 -max -1
expr1 -type int -optional 0
then -type literal(then) -optional 1
body1 -type int -optional 0
"elseif_clause" -type {literal(elseif) int ?literal(then)? int} -optional 1 -multiple 1
"else_clause" -type {?literal(else)? int} -optional 1 -multiple 0
}
}
variable cleanup {
punk::args::undefine ::testspace::alloc_range 1
punk::args::undefine ::testspace::alloc_if 1
}
#ok/status plus badarg (failed) or receivednames (ok) - compact per-case signature
proc pstat {arglist id} {
set st [punk::args::parse_status $arglist withid $id]
if {[dict get $st ok]} {
return [list 1 [dict get $st status] [dict get $st receivednames]]
}
return [list 0 [dict get $st status] [dict get $st badarg]]
}
#added 2026-07-12 (agent, G-071) - regression guards: allocation with the
#optional noise word present (or no step) works today and must keep working
test allocation_range_noiseword_variants {lseq-range shape: variants with the sep noise word present (or only start end) allocate correctly}\
-setup $common -body {
lappend result [pstat {1 2} ::testspace::alloc_range]
lappend result [pstat {1 to 2} ::testspace::alloc_range]
lappend result [pstat {1 to 2 3} ::testspace::alloc_range]
lappend result [pstat {1 .. 2 by 3} ::testspace::alloc_range]
lappend result [pstat {1 to 2 by 3} ::testspace::alloc_range]
}\
-cleanup $cleanup\
-result [list\
{1 valid {start end}}\
{1 valid {start sep end}}\
{1 valid {start sep end {by step}}}\
{1 valid {start sep end {by step}}}\
{1 valid {start sep end {by step}}}\
]
#added 2026-07-12 (agent, G-071) - was allocation_range_optskip_bare_step_GAP
#(pinned {0 invalid sep}); flipped by the G-071 allocation choice screen
test allocation_range_optskip_bare_step {sep absent + bare step ('1 2 3'): non-choice word yields past the optional 'sep' to end + clause}\
-setup $common -body {
lappend result [pstat {1 2 3} ::testspace::alloc_range]
#the clause's optional literalprefix member is omitted (empty)
lappend result [dict get [dict get [punk::args::parse {1 2 3} withid ::testspace::alloc_range] values] {by step}]
}\
-cleanup $cleanup\
-result [list {1 valid {start end {by step}}} {{} 3}]
#added 2026-07-12 (agent, G-071) - was allocation_range_optskip_by_clause_GAP
#(pinned {0 incomplete end}); flipped by the G-071 allocation choice screen
test allocation_range_optskip_by_clause {sep absent + by-clause ('1 2 by 3'): allocation yields correctly and the clause takes both members}\
-setup $common -body {
lappend result [pstat {1 2 by 3} ::testspace::alloc_range]
lappend result [dict get [dict get [punk::args::parse {1 2 by 3} withid ::testspace::alloc_range] values] {by step}]
#prefix semantics survive the allocation screen: 'b' prefixes the clause
#literal, 't' prefixes the sep choice (normalized to 'to')
lappend result [dict get [dict get [punk::args::parse {1 2 b 3} withid ::testspace::alloc_range] values] {by step}]
lappend result [dict get [dict get [punk::args::parse {1 t 2 3} withid ::testspace::alloc_range] values] sep]
}\
-cleanup $cleanup\
-result [list {1 valid {start end {by step}}} {by 3} {by 3} to]
#added 2026-07-12 (agent, G-071) - was allocation_range_excess_invalid_misblame_GAP
#(pinned {0 invalid sep}); the arglist stays invalid (per lseq.n no form accepts
#it) but the blame no longer lands on the unrelated optional 'sep' - it is now
#the same excess-values rejection as the noise-word twin '1 to 2 3 4'
test allocation_range_excess_invalid {genuinely invalid '1 2 3 4' is rejected as excess values without blaming 'sep'}\
-setup $common -body {
pstat {1 2 3 4} ::testspace::alloc_range
}\
-cleanup $cleanup\
-result {0 invalid {}}
#added 2026-07-12 (agent, G-071) - guard: with the noise word present the
#equivalent excess arglist is rejected without touching 'sep'
test allocation_range_excess_with_noiseword_invalid {lseq-range shape: '1 to 2 3 4' rejected (excess values)}\
-setup $common -body {
set st [punk::args::parse_status {1 to 2 3 4} withid ::testspace::alloc_range]
list [dict get $st ok] [dict get $st status]
}\
-cleanup $cleanup\
-result {0 invalid}
#added 2026-07-12 (agent, G-071) - regression guards: the ::if noise-word
#shapes (mid-clause ?literal(then)?, clause-leading ?literal(else)?) allocate
#correctly today and must not regress with the allocator fix
test allocation_if_noiseword_guards {if shape: mid-clause and clause-leading optional literals allocate correctly}\
-setup $common -body {
foreach c {
{1 2}
{1 then 2}
{1 2 else 3}
{1 2 3}
{1 2 elseif 3 4}
{1 2 elseif 3 then 4 else 5}
{1 then 2 elseif 3 then 4 elseif 5 6 else 7}
} {
set st [punk::args::parse_status $c withid ::testspace::alloc_if]
lappend result [list [dict get $st ok] [dict get $st status]]
}
#incomplete elseif clause is rejected
set st [punk::args::parse_status {1 2 elseif 3} withid ::testspace::alloc_if]
lappend result [list [dict get $st ok] [dict get $st status]]
}\
-cleanup $cleanup\
-result [list {1 valid} {1 valid} {1 valid} {1 valid} {1 valid} {1 valid} {1 valid} {0 invalid}]
#added 2026-07-12 (agent, G-071) - characterization correcting an initial
#mis-premise: parse_status DOES accept -form, positioned per its documented
#synopsis (options BEFORE the withid/withdef tail); a trailing -form is
#rejected like any misplaced option
test parsestatus_form_option_order {parse_status -form works before the withid tail and is rejected trailing}\
-setup $common -body {
set st [punk::args::parse_status {1 to 2} -form 0 withid ::testspace::alloc_range]
lappend result [list [dict get $st ok] [dict get $st status]]
#the status structure reports the form used
lappend result [dict get $st form]
lappend result [catch {punk::args::parse_status {1 to 2} withid ::testspace::alloc_range -form 0}]
}\
-cleanup $cleanup\
-result [list {1 valid} _default 1]
cleanupTests
}
namespace delete ::testspace

96
src/tests/modules/punk/args/testsuites/args/dynamic.test

@ -0,0 +1,96 @@
package require tcltest
package require punk::args
#@dynamic definition resolution behaviour - added 2026-07-13 (agent, user-reported
#repeated bad-@dynamic warnings from 'i join').
#A definition marked @dynamic whose round-1 tstr output contains no round-2 ${...}
#parameters is a "bad @dynamic tag": the tag buys nothing (display-field ${...} is
#deferred regardless - see the define help's interpolation section). resolve warns -
#and since 2026-07-13 caches the round-1 output as a zero-param unresolved entry, so
#the warning emits ONCE per definition and later resolves skip the re-mask/re-tstr of
#round 1 (the same round-1 freezing legitimately dynamic definitions get).
#Legitimately dynamic definitions are unaffected: round-2 parameters re-substitute on
#every resolve.
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
#capture stderr written during script (chan push transform; popped in all paths)
proc capture_stderr {script} {
variable caught ""
chan push stderr {apply {{cmd chan args} {
switch -- $cmd {
initialize {return {initialize finalize write}}
finalize {return}
write {append ::testspace::caught [lindex $args 0]; return ""}
}
}}}
set code [catch {uplevel 1 $script} r ropts]
chan pop stderr
if {$code} {
return -options $ropts $r
}
return $caught
}
test dynamic_bad_tag_warns_once {a @dynamic definition with no round-2 parameters warns once and is served from the round-1 cache thereafter}\
-setup $common -body {
punk::args::define {
@dynamic
@id -id ::testspace::baddyn
@cmd -name testspace::baddyn -help "display-only placeholder ${$display_only_content}"
@values -min 1 -max 1
v -type string
}
set errout [capture_stderr {
punk::args::get_spec ::testspace::baddyn
punk::args::get_spec ::testspace::baddyn
punk::args::parse {hello} withid ::testspace::baddyn
punk::args::parse_status {hello} withid ::testspace::baddyn
}]
lappend result [regexp -all {bad @dynamic tag for id:::testspace::baddyn} $errout]
#the cached round-1 output still parses correctly
set argd [punk::args::parse {world} withid ::testspace::baddyn]
lappend result [dict get $argd values v]
}\
-cleanup {
punk::args::undefine ::testspace::baddyn 1
}\
-result [list 1 world]
test dynamic_legit_round2_stays_live {a legitimately dynamic definition re-substitutes round-2 parameters on every resolve, with no bad-tag warning}\
-setup $common -body {
variable dynchoices {a b}
proc ::testspace::choices_now {} {
variable dynchoices
return $dynchoices
}
namespace eval ::testspace::dynfix {
set DYN_CHOICES {${[::testspace::choices_now]}}
punk::args::define {
@dynamic
@id -id ::testspace::gooddyn
@cmd -name testspace::gooddyn -help "legit dynamic"
@values -min 1 -max 1
v -choices {${$DYN_CHOICES}}
}
}
set errout [capture_stderr {
lappend result [dict get [punk::args::get_spec ::testspace::gooddyn] FORMS _default ARG_INFO v -choices]
set ::testspace::dynchoices {c d}
lappend result [dict get [punk::args::get_spec ::testspace::gooddyn] FORMS _default ARG_INFO v -choices]
}]
lappend result [regexp -all {bad @dynamic tag} $errout]
}\
-cleanup {
namespace delete ::testspace::dynfix
rename ::testspace::choices_now {}
punk::args::undefine ::testspace::gooddyn 1
}\
-result [list {a b} {c d} 0]
}
tcltest::cleanupTests ;#needed to produce test summary line.

176
src/tests/modules/punk/args/testsuites/args/forms.test

@ -6,13 +6,14 @@ package require punk::ns
#Multi-form definition (@form directive) characterization - added 2026-07-08 ahead of G-041
#(punk::args multi-form matching: automated form selection for parsing and documentation).
#Tests marked GAP pin the CURRENT behaviour that G-041 changes:
# - punk::args::parse without -form (default *) does not attempt non-zero forms: input
# matching only a later form fails with form 0's type/arity error
# - -form is documented as accepting a set/list of forms but currently accepts only a
# single form name or integer index
#The explicit single -form selections and the synopsis/spec structure tests are expected
#to remain valid after G-041.
#G-041 implemented 2026-07-13: parse without -form (default *) attempts every permitted
#form - a clean match against exactly one form is auto-selected (result 'form' key
#records it, 'formstatus' reports every candidate), no match raises a noformmatch error
#naming each candidate form's failure (ranked best-candidate first), several matches
#raise a multipleformmatches error, and -form accepts a list of form names/indices as
#documented. The former GAP pins (forms_parse_default_form0_only_GAP,
#forms_parse_formlist_rejected_GAP) flipped to forms_parse_autoselect and
#forms_parse_formlist_restriction.
namespace eval ::testspace {
namespace import ::tcltest::*
@ -110,39 +111,155 @@ namespace eval ::testspace {
{idle idle scripts {{puts hi}}}\
]
test forms_parse_default_form0_only_GAP {GAP (G-041): without -form, input matching only a later form fails with form 0's error}\
#flipped from forms_parse_default_form0_only_GAP 2026-07-13 (agent, G-041)
test forms_parse_autoselect {without -form, input cleanly matching exactly one form is auto-selected (result form key records it)}\
-setup $common -body {
#form 0 input parses fine without -form
set argd [punk::args::parse {450} withid ::testspace::afterish]
lappend result [dict get $argd values]
#input matching only the cancel form is NOT auto-selected - fails against form 0 (ms -type int)
if {[catch {punk::args::parse {cancel after#1} withid ::testspace::afterish} errmsg]} {
lappend result [string match "*Bad number of values*" $errmsg]
foreach arglist {
{450 {puts hi}}
{cancel after#1 after#2}
{idle {puts hi}}
} {
set argd [punk::args::parse $arglist withid ::testspace::afterish]
lappend result [dict get $argd form] [dict get $argd values]
}
set result
}\
-cleanup {
}\
-result [list\
ms {ms 450 script {{puts hi}}}\
cancel {cancel cancel ids {after#1 after#2}}\
idle {idle idle scripts {{puts hi}}}\
]
#added 2026-07-13 (agent, G-041)
test forms_parse_autoselect_shared_prologue {auto-selection distinguishes forms sharing a leader block by their value arity}\
-setup $common -body {
set argd [punk::args::parse {mykey} withid ::testspace::sharedform]
lappend result [dict get $argd form]
set argd [punk::args::parse {mykey myvalue} withid ::testspace::sharedform]
lappend result [dict get $argd form] [dict get $argd values]
}\
-cleanup {
}\
-result [list get set {newvalue myvalue}]
#added 2026-07-13 (agent, G-041)
test forms_parse_autoselect_formstatus {a multi-form auto-selected parse reports every candidate form's status in the result}\
-setup $common -body {
set argd [punk::args::parse {cancel after#1} withid ::testspace::afterish]
set formstatus [dict get $argd formstatus]
lappend result [dict keys $formstatus]
lappend result [dict get $formstatus cancel status]
lappend result [dict get $formstatus ms status]
#single-form parses take the direct path - no formstatus key, form key present
set argd [punk::args::parse {a b} withdef {
@id -id ::testspace::formsingle
x -type string
y -type string
}]
lappend result [dict get $argd form] [dict exists $argd formstatus]
}\
-cleanup {
}\
-result [list {ms cancel idle} valid incomplete _default 0]
#added 2026-07-13 (agent, G-041)
test forms_parse_noformmatch {input matching no form raises an error naming each candidate form's failure - best candidate (literal affinity) first}\
-setup $common -body {
#'cancel' alone: the cancel form is incomplete (ids missing), ms/idle don't fit
if {[catch {punk::args::parse {cancel} -errorstyle minimal withid ::testspace::afterish} errmsg erroropts]} {
set ecode [dict get $erroropts -errorcode]
set classinfo [lindex $ecode 2]
lappend result [lindex $classinfo 0]
#ranked candidates: leading-literal agreement puts the cancel form first
lappend result [lindex [dict get [lrange $classinfo 1 end] forms] 0]
lappend result [string match "*No form of the command matches*" $errmsg]
foreach fname {ms cancel idle} {
lappend result [string match "*form '$fname':*" $errmsg]
}
} else {
lappend result UNEXPECTED-autoselected-cancel-form
lappend result UNEXPECTED-parsed
}
#same for the idle form
if {[catch {punk::args::parse {idle {puts hi}} withid ::testspace::afterish}]} {
lappend result idle-not-autoselected
set result
}\
-cleanup {
}\
-result [list noformmatch cancel 1 1 1 1]
#added 2026-07-13 (agent, G-041)
test forms_parse_multipleformmatches {input cleanly matching several permitted forms raises an error naming them - explicit -form disambiguates}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::ambigform
@cmd -name testspace::ambigform -summary "ambiguous multiform" -help "two forms accepting one string"
@form -form alpha
@values -min 1 -max 1
v1 -type string
@form -form beta
@values -min 1 -max 1
v2 -type string
}
if {[catch {punk::args::parse {hello} -errorstyle minimal withid ::testspace::ambigform} errmsg erroropts]} {
set classinfo [lindex [dict get $erroropts -errorcode] 2]
lappend result [lindex $classinfo 0]
lappend result [dict get [lrange $classinfo 1 end] forms]
} else {
lappend result UNEXPECTED-autoselected-idle-form
lappend result UNEXPECTED-parsed
}
set argd [punk::args::parse {hello} -form beta withid ::testspace::ambigform]
lappend result [dict get $argd form] [dict get $argd values]
}\
-cleanup {
punk::args::undefine ::testspace::ambigform 1
}\
-result [list {ms 450} 1 idle-not-autoselected]
-result [list multipleformmatches {alpha beta} beta {v2 hello}]
test forms_parse_formlist_rejected_GAP {GAP (G-041): -form documented as a list of forms but only a single name/index is accepted}\
#flipped from forms_parse_formlist_rejected_GAP 2026-07-13 (agent, G-041)
test forms_parse_formlist_restriction {-form accepts a list of form names/indices and restricts candidacy to that subset}\
-setup $common -body {
if {[catch {punk::args::parse {idle {puts hi}} -form {cancel idle} withid ::testspace::afterish} errmsg]} {
#auto-selection within the listed subset
set argd [punk::args::parse {idle {puts hi}} -form {cancel idle} withid ::testspace::afterish]
lappend result [dict get $argd form]
#indices work in the list as for single values
set argd [punk::args::parse {cancel after#1} -form {1 2} withid ::testspace::afterish]
lappend result [dict get $argd form]
#input matching only an excluded form is not considered
if {[catch {punk::args::parse {450} -form {cancel idle} -errorstyle minimal withid ::testspace::afterish} errmsg erroropts]} {
set classinfo [lindex [dict get $erroropts -errorcode] 2]
lappend result [lindex $classinfo 0] [lsort [dict get [lrange $classinfo 1 end] forms]]
} else {
lappend result UNEXPECTED-accepted-excluded-form-input
}
#an unrecognised element still errors as for single values
if {[catch {punk::args::parse {450} -form {ms bogusform} withid ::testspace::afterish} errmsg]} {
lappend result [string match "*invalid -form value*" $errmsg]
} else {
lappend result UNEXPECTED-accepted-form-list
lappend result UNEXPECTED-accepted-bogus-form
}
}\
-cleanup {
}\
-result [list 1]
-result [list idle cancel noformmatch {cancel idle} 1]
#added 2026-07-13 (agent, G-041)
test forms_parsestatus_form_autoselect {parse_status reports the auto-selected form, and for noformmatch the best-candidate form with per-form statuses}\
-setup $common -body {
#valid: form key = the matched form; formstatus covers every candidate
set pstat [punk::args::parse_status {cancel after#1} withid ::testspace::afterish]
lappend result [dict get $pstat ok] [dict get $pstat form]
lappend result [dict get $pstat formstatus cancel status] [dict get $pstat formstatus idle status]
#noformmatch: per-argument statuses are built for the best candidate (cancel)
#and formstatus messages carry caller attribution (%caller% substituted)
set pstat [punk::args::parse_status {cancel} -caller afterish withid ::testspace::afterish]
lappend result [dict get $pstat ok] [dict get $pstat status] [dict get $pstat failureclass]
lappend result [dict get $pstat form]
lappend result [string match "*%caller%*" [dict get $pstat formstatus ms message]]
lappend result [string match "*afterish*" [dict get $pstat formstatus ms message]]
}\
-cleanup {
}\
-result [list 1 cancel valid incomplete 0 incomplete noformmatch cancel 0 1]
test forms_synopsis_lists_all_forms {punk::ns::synopsis renders a synopsis line per form}\
-setup $common -body {
@ -160,12 +277,13 @@ namespace eval ::testspace {
}\
-result [list 1 1 1]
test forms_form_synopsis_override_stored_not_rendered_GAP {GAP: @form -synopsis override is stored in the spec but ignored by synopsis rendering}\
#flipped from forms_form_synopsis_override_stored_not_rendered_GAP 2026-07-13 (agent, G-041)
test forms_form_synopsis_override_rendered {@form -synopsis override is stored in the spec and replaces the auto-calculated synopsis line}\
-setup $common -body {
#documented: "The -synopsis value allows overriding the auto-calculated synopsis"
#current behaviour: the override is recorded in the spec (FORMS <form> -synopsis)
#but punk::ns::synopsis renders the auto-calculated synopsis regardless.
#(doc-surface work adjacent to G-041; flip the last expectation when fixed)
#the override is recorded in the spec (FORMS <form> -synopsis) and rendered by
#punk::args::synopsis (and punk::ns::synopsis passthrough) in place of the
#auto-calculated line - arg_error's synopsis section honoured it already
punk::args::define {
@id -id ::testspace::synoverride
@cmd -name testspace::synoverride -summary "synopsis override" -help "synopsis override fixture"
@ -187,7 +305,7 @@ namespace eval ::testspace {
}\
-result [list\
{synoverride --custom-synopsis-text}\
0\
1\
]
}
tcltest::cleanupTests ;#needed to produce test summary line.

192
src/tests/modules/punk/args/testsuites/args/normalize.test

@ -0,0 +1,192 @@
package require tcltest
package require punk::args
package require punk::ansi
#@normalize directive (G-045): opt-in indent normalization of BLOCK-FORM multi-line
#field values for constructed (string-built) definitions. Semantics (user-confirmed
#re-base 2026-07-12; narrowed to block form during implementation): a value whose
#first line is whitespace-only is a block - the structural first newline and a
#whitespace-only trailing line are dropped, the content lines' common leading
#whitespace is the block's base indent, the first content line is unindented fully
#and subsequent lines are re-based to the 4-space convention (deeper relative
#indents preserved). Head-form values are NEVER altered: their base is ambiguous
#(uniform continuation indent may be the deliberate +2 relative convention over
#base 4, or a deeper flush base) - which also makes @normalize a no-op on
#conforming file-style definitions. Fields in a record's -unindentedfields are
#exempt. Without @normalize, constructed-def behaviour is unchanged - see the P4
#characterization in rendering.test.
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
proc render_table {id} {
return [punk::ansi::ansistrip [punk::args::arg_error "" [punk::args::get_spec $id] -aserror 0]]
}
#column (0-based) at which a unique marker string appears in the rendered text, -1 if absent
proc markercol {rendered marker} {
foreach ln [split $rendered \n] {
set ix [string first $marker $ln]
if {$ix >= 0} {
return $ix
}
}
return -1
}
#added 2026-07-12 (agent, G-045) - the head-form boundary: base indent is
#ambiguous for head-form values so @normalize leaves them alone (deep indents
#leak exactly as in the unopted P4 characterization: display strips only 4)
test normalize_headform_untouched {@normalize: head-form values (content on the first line) are not altered - deep continuation indents render as authored}\
-setup $common -body {
set help "NFIRST line\n NFLUSH line\n NPLUS2 line"
set def ""
append def "@normalize" \n
append def "@id -id ::testspace::nz_head" \n
append def "@cmd -name testspace::nz_head -summary \"Head.\" -help \"$help\"" \n
append def "@values -min 0 -max 0"
punk::args::define $def
set r [render_table ::testspace::nz_head]
set n0 [markercol $r NFIRST]
lappend result [expr {[markercol $r NFLUSH] - $n0}]
lappend result [expr {[markercol $r NPLUS2] - $n0}]
}\
-cleanup {
punk::args::undefine ::testspace::nz_head 1
}\
-result [list 12 14]
#added 2026-07-12 (agent, G-045) - the block-form ergonomics: a braced value
#authored as a clean indented block (leading newline, closing-brace line) needs
#no manual string trim
test normalize_blockform_leading_newline {@normalize: block-form value (whitespace-only first line) drops the structural newline and renders flush}\
-setup $common -body {
set help {
BFIRST line
BFLUSH line
BPLUS2 line
}
set def ""
append def "@normalize" \n
append def "@id -id ::testspace::nz_block" \n
append def "@cmd -name testspace::nz_block -summary \"Block.\" -help \"$help\"" \n
append def "@values -min 0 -max 0"
punk::args::define $def
set r [render_table ::testspace::nz_block]
set b0 [markercol $r BFIRST]
lappend result [expr {[markercol $r BFLUSH] - $b0}]
lappend result [expr {[markercol $r BPLUS2] - $b0}]
#the structural leading newline is gone: BFIRST appears on the same
#rendered line as the Description: label
set desc_line ""
foreach ln [split $r \n] {
if {[string first "Description:" $ln] >= 0} {
set desc_line $ln
break
}
}
lappend result [expr {[string first "BFIRST" $desc_line] >= 0}]
}\
-cleanup {
punk::args::undefine ::testspace::nz_block 1
}\
-result [list 0 2 1]
#added 2026-07-12 (agent, G-045) - block form with zero base indent: left-margin
#content behind a structural leading newline re-bases to the convention (no
#-unindentedfields declaration needed)
test normalize_blockform_leftmargin_gains_base {@normalize: a block-form value with left-margin content is re-based and renders flush}\
-setup $common -body {
set help "\nLFIRST line\nLFLUSH line"
set def ""
append def "@normalize" \n
append def "@id -id ::testspace::nz_left" \n
append def "@cmd -name testspace::nz_left -summary \"Left.\" -help \"$help\"" \n
append def "@values -min 0 -max 0"
punk::args::define $def
set r [render_table ::testspace::nz_left]
lappend result [expr {[markercol $r LFLUSH] - [markercol $r LFIRST]}]
}\
-cleanup {
punk::args::undefine ::testspace::nz_left 1
}\
-result [list 0]
#added 2026-07-12 (agent, G-045) - exemption: an -unindentedfields field keeps
#its block-form value byte-exact (structural newline preserved, no re-base, no
#display transform) where a non-exempt block would lose the leading blank line
test normalize_unindentedfields_exempt {@normalize: fields in a record's -unindentedfields are exempt - block-form value kept literally}\
-setup $common -body {
set help "\n EFIRST line\n ESIX line"
set def ""
append def "@normalize" \n
append def "@id -id ::testspace::nz_exempt" \n
append def "@cmd -name testspace::nz_exempt -summary \"Exempt.\" -unindentedfields {-help} -help \"$help\"" \n
append def "@values -min 0 -max 0"
punk::args::define $def
set r [render_table ::testspace::nz_exempt]
#leading structural newline preserved: EFIRST is NOT on the Description: line
set desc_line ""
foreach ln [split $r \n] {
if {[string first "Description:" $ln] >= 0} {
set desc_line $ln
break
}
}
lappend result [expr {[string first "EFIRST" $desc_line] < 0}]
#6-space indents kept literally - both lines at the same column
lappend result [expr {[markercol $r ESIX] - [markercol $r EFIRST]}]
}\
-cleanup {
punk::args::undefine ::testspace::nz_exempt 1
}\
-result [list 1 0]
#added 2026-07-12 (agent, G-045) - idempotence: @normalize on a conforming
#file-style braced definition changes nothing
test normalize_idempotent_on_filestyle {@normalize: a braced file-style definition renders identically with and without the directive}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::nz_fplain
@cmd -name testspace::nz_fstyle -summary\
"Filestyle."\
-help\
"FFIRST line.
FFLUSH line.
FPLUS2 line."
@values -min 1 -max 1
v1 -type string -help\
"val help line1
val help line2"
}
punk::args::define {
@normalize
@id -id ::testspace::nz_fnorm2
@cmd -name testspace::nz_fstyle -summary\
"Filestyle."\
-help\
"FFIRST line.
FFLUSH line.
FPLUS2 line."
@values -min 1 -max 1
v1 -type string -help\
"val help line1
val help line2"
}
set r_plain [render_table ::testspace::nz_fplain]
set r_norm [render_table ::testspace::nz_fnorm2]
#ids deliberately same length (nz_fplain/nz_fnorm2) so table geometry matches
lappend result [expr {[string map {nz_fnorm2 nz_fplain} $r_norm] eq $r_plain}]
}\
-cleanup {
punk::args::undefine ::testspace::nz_fplain 1
punk::args::undefine ::testspace::nz_fnorm2 1
}\
-result [list 1]
cleanupTests
}
namespace delete ::testspace

182
src/tests/modules/punk/args/testsuites/args/recordcontinuation.test

@ -0,0 +1,182 @@
package require tcltest
package require punk::args
package require punk::ansi
#Record-continuation token -& (G-045): an unquoted trailing -& element continues a
#definition record on the next line, rewritten internally to a backslash
#line-continuation so the assembled record is byte-identical to its
#backslash-continued equivalent. Key properties pinned here:
# - equivalence: a -& definition parses AND renders identically to its
# backslash-continuation twin (continuation is additive - backslash authoring
# unchanged)
# - the motivating case: constructed (string-built) definitions can use -& where
# a backslash-newline would be consumed by the building code's own quoting
# - collision/escape rules: a braced trailing {-&} is data; -& mid-line is data;
# a word merely ending in the characters -& is data; -& on a line inside a
# still-open braced value is data
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
proc render_table {id} {
return [punk::ansi::ansistrip [punk::args::arg_error "" [punk::args::get_spec $id] -aserror 0]]
}
#added 2026-07-12 (agent, G-045)
#the two ids are deliberately the same length: the id appears in the rendered
#synopsis, so differing lengths would change table geometry and defeat the
#render-equality comparison after the id string map
test recordcontinuation_amp_equivalent_to_backslash {a definition using trailing -& parses and renders identically to its backslash-continuation equivalent}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::rc_bsl
@cmd -name testspace::rc_fixture -summary\
"Fixture."\
-help\
"CFIRST line.
CFLUSH line."
@opts
-o1 -type string -default od1 -help\
"opt help line1
opt help line2"
@values -min 1 -max 1
v1 -type string -help\
"val help."
}
punk::args::define {
@id -id ::testspace::rc_amp
@cmd -name testspace::rc_fixture -summary -&
"Fixture." -&
-help -&
"CFIRST line.
CFLUSH line."
@opts
-o1 -type string -default od1 -help -&
"opt help line1
opt help line2"
@values -min 1 -max 1
v1 -type string -help -&
"val help."
}
set r_b [punk::args::parse {-o1 X VAL1} withid ::testspace::rc_bsl]
set r_a [punk::args::parse {-o1 X VAL1} withid ::testspace::rc_amp]
lappend result [expr {[string map {rc_amp rc_bsl} $r_a] eq $r_b}]
set t_b [render_table ::testspace::rc_bsl]
set t_a [render_table ::testspace::rc_amp]
lappend result [expr {[string map {rc_amp rc_bsl} $t_a] eq $t_b}]
}\
-cleanup {
punk::args::undefine ::testspace::rc_bsl 1
punk::args::undefine ::testspace::rc_amp 1
}\
-result [list 1 1]
#added 2026-07-12 (agent, G-045) - the motivating case: string-built definitions
#cannot use backslash-newline (consumed by the builder's own quoting) but can use -&
test recordcontinuation_amp_constructed_definition {a constructed (string-built) definition using -& chains record lines, including into a multi-line quoted value}\
-setup $common -body {
set def ""
append def "@id -id ::testspace::rc_chain" \n
append def "@cmd -name testspace::rc_chain -summary \"Chain.\" -help \"chain help\"" \n
append def "@opts" \n
append def "-o1 -type string -&" \n
append def "-default HDEF -& " \n
append def "-help \"h line1" \n
append def "h line2\"" \n
append def "@values -min 0 -max 0"
punk::args::define $def
set argd [punk::args::parse {} withid ::testspace::rc_chain]
lappend result [dict get [dict get $argd opts] -o1]
set t [render_table ::testspace::rc_chain]
lappend result [expr {[string first "h line1" $t] >= 0}]
lappend result [expr {[string first "h line2" $t] >= 0}]
}\
-cleanup {
punk::args::undefine ::testspace::rc_chain 1
}\
-result [list HDEF 1 1]
#added 2026-07-12 (agent, G-045)
test recordcontinuation_amp_braced_escape_is_data {a braced trailing {-&} is a literal value - the record ends and the next line is a separate record}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::rc_braced
@cmd -name testspace::rc_braced -summary "Braced." -help "braced help"
@opts
-o1 -type string -default {-&}
-o2 -type string -default d2
@values -min 0 -max 0
}
set argd [punk::args::parse {} withid ::testspace::rc_braced]
lappend result [dict get [dict get $argd opts] -o1]
lappend result [dict get [dict get $argd opts] -o2]
}\
-cleanup {
punk::args::undefine ::testspace::rc_braced 1
}\
-result [list -& d2]
#added 2026-07-12 (agent, G-045)
test recordcontinuation_amp_midline_is_data {an unquoted -& that is not the last element on the line is an ordinary value}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::rc_midline
@cmd -name testspace::rc_midline -summary "Midline." -help "midline help"
@opts
-o1 -default -& -type string
@values -min 0 -max 0
}
set argd [punk::args::parse {} withid ::testspace::rc_midline]
lappend result [dict get [dict get $argd opts] -o1]
}\
-cleanup {
punk::args::undefine ::testspace::rc_midline 1
}\
-result [list -&]
#added 2026-07-12 (agent, G-045)
test recordcontinuation_amp_wordend_is_data {a word merely ending in the characters -& is not a continuation token}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::rc_wordend
@cmd -name testspace::rc_wordend -summary "Wordend." -help "wordend help"
@opts
-o1 -type string -default abc-&
-o2 -type string -default d2
@values -min 0 -max 0
}
set argd [punk::args::parse {} withid ::testspace::rc_wordend]
lappend result [dict get [dict get $argd opts] -o1]
lappend result [dict get [dict get $argd opts] -o2]
}\
-cleanup {
punk::args::undefine ::testspace::rc_wordend 1
}\
-result [list abc-& d2]
#added 2026-07-12 (agent, G-045)
test recordcontinuation_amp_inside_braces_is_data {a line ending in -& inside a still-open braced value stays literal data}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::rc_inbrace
@cmd -name testspace::rc_inbrace -summary "Inbrace." -help "inbrace help"
@values -min 1 -max 1
v1 -type string -help {AFIRST -&
ASECOND line}
}
set t [render_table ::testspace::rc_inbrace]
lappend result [expr {[string first "AFIRST -&" $t] >= 0}]
lappend result [expr {[string first "ASECOND line" $t] >= 0}]
}\
-cleanup {
punk::args::undefine ::testspace::rc_inbrace 1
}\
-result [list 1 1]
cleanupTests
}
namespace delete ::testspace

29
src/tests/modules/punk/args/testsuites/args/rendering.test

@ -15,14 +15,16 @@ package require punk::ansi
# P2 relative-indent preservation: the documenter's continuation-line indentation
# conventions (e.g. lines 2+ indented by 2) survive to display, measured relative
# to the first line - punk::args preserves but does not enforce them
# P3 -unindentedfields: left-margin authoring works for argument -help (and is
# currently accepted but IGNORED for @cmd -help - pinned as a GAP)
# P3 -unindentedfields: left-margin authoring works for argument -help and for
# @cmd -help (the @cmd gate was a pinned GAP until G-045 honoured it, 2026-07-12)
# P4 constructed (string-built) definitions receive no whole-block indent
# normalization ('constructed' - not to be confused with the @dynamic directive):
# embedded continuation indentation is interpreted via the display-time
# undent(prefix4,max4) transform, so continuations written at exactly 4 spaces
# align flush with the first line, and any other depth misaligns (pinned as
# characterization - this is why ::punk::helptopic::define_docs pre-normalizes)
# align flush with the first line, and any other depth misaligns. This remains
# the (deliberate) default: G-045 added the opt-in @normalize directive for
# block-form values (see normalize.test) and this characterization pins the
# unopted behaviour builders rely on
# P5 tstr ${...} substitutions with MULTILINE results (command or variable) insert
# aligned at the insertion column with the inserted text's own relative indents
# preserved (the -paramindents machinery) - including whole-definition insertion
@ -251,12 +253,13 @@ namespace eval ::testspace {
}\
-result [list 2]
test rendering_unindentedfields_cmd_help_GAP {GAP: @cmd accepts -unindentedfields but ignores it - left-margin authored cmd-help misaligns (first line +4 vs continuations)}\
test rendering_unindentedfields_cmd_help {@cmd -help with -unindentedfields: left-margin authoring renders aligned}\
-setup $common -body {
#the display transform undent(" "+help, max 4) still applies: with
#continuations at the left margin the common prefix is 0, nothing is
#removed, and the first line keeps its injected 4-space prefix.
#(see punk::args arg_error cmd_info handling: '#unindentedfields ?' todo)
#-unindentedfields {-help} on the @cmd line gates the display transform
#undent(" "+help, max 4), so left-margin authored cmd-help keeps its
#first line flush with continuations.
#(was rendering_unindentedfields_cmd_help_GAP pinning the ignored-option
#behaviour - first line +4; flipped by G-045 2026-07-12)
punk::args::define {
@id -id ::testspace::rcmdunind
@cmd -name testspace::rcmdunind -summary\
@ -268,17 +271,19 @@ GFLUSH line"
@values -min 0 -max 0
}
set r [render_table ::testspace::rcmdunind]
#pinned CURRENT behaviour: first line renders 4 right of its continuation
lappend result [expr {[markercol $r GFIRST] - [markercol $r GFLUSH]}]
#and the string renderer shares the gated transform
set rs [render_string ::testspace::rcmdunind]
lappend result [expr {[markercol $rs GFIRST] - [markercol $rs GFLUSH]}]
}\
-cleanup {
punk::args::undefine ::testspace::rcmdunind 1
}\
-result [list 4]
-result [list 0 0]
#--- P4 constructed (string-built) definitions -----------------------------------------
test rendering_constructed_def_indent_characterization {constructed (string-built) definitions: continuation indentation is absolute - exactly 4 spaces aligns flush; other depths shift (why constructed-def builders must pre-normalize)}\
test rendering_constructed_def_indent_characterization {constructed (string-built) definitions: continuation indentation is absolute - exactly 4 spaces aligns flush; other depths shift (opt-in remedy: the G-045 @normalize directive, normalize.test)}\
-setup $common -body {
#continuations at exactly 4 -> aligned flush with first line (the current
#correct authoring convention for constructed definitions)

63
src/tests/modules/punk/args/testsuites/args/tclcoreparity.test

@ -147,5 +147,68 @@ namespace eval ::testspace {
-cleanup {
}\
-result [list [expr {"dict" in [live_classes]}] [expr {"unicode" in [live_classes]}] 1]
#--- ::after cancel-id discrimination (user-directed 2026-07-13; probe record in
#--- goals/G-055) - the cancelid/info forms' id is typed by the id shape harvested
#--- from the running interpreter, so 'after cancel <non-id>' resolves to the
#--- cancelscript form exactly as real Tcl treats it (silent script-match no-op).
#added 2026-07-13 (agent, G-055 subject matter, post G-041)
test tclcoreparity_after_id_shape_harvested {the doc model's id type matches the shape of ids the running interpreter actually issues}\
-constraints have_tclcoredocs\
-setup $common -body {
#live id shape (create + immediately cancel - no event loop, no lasting state)
set liveid [after 999999 {}]
after cancel $liveid
set doctype [dict get [punk::args::get_spec ::after] FORMS cancelid ARG_INFO id -type]
lappend result [string match {stringstartswith(*)} $doctype]
set docprefix [string range $doctype 17 end-1]
lappend result [string match "${docprefix}*" $liveid]
#info form uses the same shape
lappend result [string equal $doctype [dict get [punk::args::get_spec ::after] FORMS info ARG_INFO id -type]]
}\
-cleanup {
}\
-result [list 1 1 1]
test tclcoreparity_after_cancel_discrimination {'after cancel <word>' form resolution matches real semantics: non-id words are scripts, id-shaped words are the genuine liveness ambiguity}\
-constraints have_tclcoredocs\
-setup $common -body {
#real: 'after cancel someid' is a silent script-match no-op - model: cancelscript
lappend result [expr {![catch {after cancel someid}]}]
set ps [punk::args::parse_status {cancel someid} withid ::after]
lappend result [dict get $ps ok] [dict get $ps form]
#multi-word script tail
set ps [punk::args::parse_status {cancel puts hi} withid ::after]
lappend result [dict get $ps ok] [dict get $ps form]
#id-SHAPED word: truthfully ambiguous (a script can carry the same text -
#real Tcl resolves by id liveness at runtime, which no static type expresses)
set liveid [after 999999 {}]
after cancel $liveid
set ps [punk::args::parse_status [list cancel $liveid] withid ::after]
lappend result [dict get $ps ok] [dict get $ps failureclass]
lappend result [lsort [dict keys [dict get $ps formstatus]]]
}\
-cleanup {
}\
-result [list 1 1 cancelscript 1 cancelscript 0 multipleformmatches {cancelid cancelscript}]
test tclcoreparity_after_info_id_parity {'after info <word>' error-vs-ok outcome agrees for non-id-shaped words; dead-id over-acceptance is the accepted runtime-liveness boundary}\
-constraints have_tclcoredocs\
-setup $common -body {
#non-id-shaped word: real errors ("event ... doesn't exist"), model rejects the shape
lappend result [expr {![catch {after info someid}]}]
lappend result [dict get [punk::args::parse_status {info someid} withid ::after] ok]
#bare 'after info' is valid on both sides
lappend result [expr {![catch {after info}]}]
lappend result [dict get [punk::args::parse_status {info} withid ::after] ok]
#accepted divergence (over-acceptance class, goals/G-055): a DEAD id-shaped
#word - real errors on liveness, the model accepts the shape
lappend result [expr {![catch {after info after#999999999}]}]
lappend result [dict get [punk::args::parse_status {info after#999999999} withid ::after] ok]
}\
-cleanup {
}\
-result [list 0 0 1 1 0 1]
}
tcltest::cleanupTests ;#needed to produce test summary line.

153
src/tests/modules/punk/ns/testsuites/ns/cmdhelp.test

@ -420,23 +420,33 @@ namespace eval ::testspace {
}\
-result [list 0 incomplete error {}]
test cmdhelp_return_dict_scheme {the dict scheme field reflects an explicit -scheme, and the no-supplied-words suppression (G-046 item 5) reports info}\
test cmdhelp_return_dict_scheme {the dict scheme field reflects an explicit -scheme, and a bare-query failure reports the error scheme}\
-setup $common -body {
set d [punk::ns::cmdhelp -return dict -scheme error ::testspace::helpfix v1 0 1]
lappend result [dict get [dict get $d parsestatus] scheme]
#leader-requiring definition with no argument words - failure reflects only the
#absent input, reported with the info scheme the display path would use
#leader-requiring definition with no argument words - the failing advisory
#parse reports the error scheme like any other failure (the G-046 item 5
#info-scheme suppression was reversed 2026-07-12: the G-049 -caller
#attribution made bare-query failure messages accurate, so 'i <cmd>' on a
#command that cannot be called bare shows that signal again)
set d [punk::ns::cmdhelp -return dict ::testspace::helpstr]
set ps [dict get $d parsestatus]
lappend result [dict get $ps ok] [dict get $ps scheme]
}\
-cleanup {
}\
-result [list error 0 info]
-result [list error 0 error]
#--- GAP pins: pseudo-command cmdtype + space-delimited docid prefixes (G-051) ------------
test cmdhelp_GAP_pseudo_command_cmdtype_notfound {a space-delimited docid below a real command resolves its documentation but reports cmdtype 'notfound'}\
#G-051 flipped pins (were cmdhelp_GAP_pseudo_command_cmdtype_notfound /
#cmdhelp_GAP_spaceform_docid_prefix_not_honoured / cmdhelp_GAP_string_is_true_pseudo /
#cmdhelp_GAP_string_is_prefix_not_honoured): cmdinfo reports cmdtype 'doconly' when
#resolution lands on a punk::args id with no corresponding real command, and the
#space-form docid jump resolves choice prefixes via punk::args::choiceword_match
#(the shared resolver - no second matching rule) when the exact word has no id.
test cmdhelp_pseudo_command_cmdtype_doconly {a space-delimited docid below a real command resolves its documentation and reports cmdtype 'doconly'}\
-setup $common -body {
set cinfo [punk::ns::cmdinfo ::testspace::helpstr is]
lappend result [dict get $cinfo docid] [dict get $cinfo cmdtype]
@ -445,22 +455,26 @@ namespace eval ::testspace {
}\
-cleanup {
}\
-result [list {::testspace::helpstr is} notfound {::testspace::helpstr is} hello]
-result [list {::testspace::helpstr is} doconly {::testspace::helpstr is} hello]
test cmdhelp_GAP_spaceform_docid_prefix_not_honoured {parse accepts a unique choice prefix for the subcommand word but the space-delimited docid jump requires the exact word}\
test cmdhelp_spaceform_docid_prefix_honoured {the space-delimited docid jump accepts the choice prefixes parse accepts, resolving to the canonical child docid}\
-setup $common -body {
#parse side: unique prefix 'i' of choice 'is' is accepted and normalized
set argd [punk::args::parse {i} withid ::testspace::helpstr]
lappend result [dict get [dict get $argd leaders] subcmd]
#doc-walk side: the same prefix does not reach the child docid
#doc-walk side: the same prefix reaches the child docid with the canonical
#word consumed
set cinfo [punk::ns::cmdinfo ::testspace::helpstr i]
lappend result [dict get $cinfo docid] [dict get $cinfo args_remaining]
#an ambiguous or unknown word still stays at the parent (parse would reject it)
set cinfo [punk::ns::cmdinfo ::testspace::helpstr zz]
lappend result [dict get $cinfo docid] [dict get $cinfo args_remaining]
}\
-cleanup {
}\
-result [list is ::testspace::helpstr i]
-result [list is {::testspace::helpstr is} {} ::testspace::helpstr zz]
test cmdhelp_GAP_string_is_true_pseudo {real-world pin: 'string is true' resolves its tclcore docid but reports cmdtype 'notfound'}\
test cmdhelp_string_is_true_pseudo_doconly {real-world pin: 'string is true' resolves its tclcore docid and reports cmdtype 'doconly'}\
-constraints have_tclcoredocs\
-setup $common -body {
set cinfo [punk::ns::cmdinfo ::string is true]
@ -468,9 +482,9 @@ namespace eval ::testspace {
}\
-cleanup {
}\
-result [list {::tcl::string::is true} notfound]
-result [list {::tcl::string::is true} doconly]
test cmdhelp_GAP_string_is_prefix_not_honoured {real-world pin: 'string is tr' works in Tcl but the doc walk stays at the parent docid}\
test cmdhelp_string_is_prefix_honoured {real-world pin: 'i string is tr' documents what 'string is tr' executes}\
-constraints have_tclcoredocs\
-setup $common -body {
#Tcl itself accepts the class prefix
@ -480,7 +494,7 @@ namespace eval ::testspace {
}\
-cleanup {
}\
-result [list 1 ::tcl::string::is tr]
-result [list 1 {::tcl::string::is true} {}]
#--- TclOO methods (G-052) -----------------------------------------------------------------
@ -546,7 +560,14 @@ namespace eval ::testspace {
}\
-result [list {{::testspace::helpfix v1} [-flag] firstval lastval}]
#--- leader-requiring definition, no supplied args (G-046 item 5) --------------------------
#--- leader-requiring definition, no supplied args -----------------------------------------
#History: G-046 item 5 suppressed the failing advisory parse for bare queries
#because the pre-G-049 message was internal-looking ("Bad number of leading values
#for punk::args::parse $args_remaining ..."). The G-049 -caller attribution made
#the messages accurate ("... for <queried command> ..."), so the suppression was
#reversed 2026-07-12 (user direction): a bare 'i <cmd>' on a command that cannot
#be called with no arguments shows the failure message and error scheme again -
#with the message attributed to the queried command, never the internal parse.
namespace eval ::testspace::leaderhelp {}
punk::args::define {
@ -562,16 +583,17 @@ namespace eval ::testspace {
}
proc ::testspace::leaderhelp::classy {zzclass} {return $zzclass}
test cmdhelp_leader_required_no_args_plain_usage {cmdhelp with no argument words for a leader-requiring definition shows plain usage - the advisory goodargs parse failure ('Bad number of leading values for punk::args::parse ...') is suppressed (G-046 item 5, the 'i string is' shape)}\
test cmdhelp_leader_required_no_args_error_render {cmdhelp with no argument words for a leader-requiring definition shows the failure message attributed to the queried command (suppression reversed; was cmdhelp_leader_required_no_args_plain_usage)}\
-setup $common -body {
set out [punk::ansi::ansistrip [punk::ns::cmdhelp -return string ::testspace::leaderhelp::classy]]
lappend result [string match "*Bad number of leading values*" $out]
#attribution is the queried command - never the internal parse call
lappend result [string match "*punk::args::parse*" $out]
lappend result [string match "*zzclass*" $out]
}\
-cleanup {
}\
-result [list 0 0 1]
-result [list 1 0 1]
#--- ensemble autodef with lazily-registered subcommand argdocs ----------------------------
#Integration surface of the defect pinned unit-level in
@ -615,5 +637,104 @@ namespace eval ::testspace {
-cleanup {
}\
-result [list 0 1 1 1 0]
#--- multiform doc surface (G-041) ---------------------------------------------------------
#added 2026-07-13 (agent, G-041): 'i <cmd> <args...>' presents the form the words
#match (auto-selected by the advisory parse) and punk::ns::synopsis underlines it.
#after-like multiform - forms discriminated by a leading literal vs an int
proc formfix {args} {}
punk::args::define {
@id -id ::testspace::formfix
@cmd -name testspace::formfix -summary "formfix summary" -help "formfix multiform"
@form -form ms
@values -min 1 -max -1
ms -type int
script -type string -optional 1 -multiple 1
@form -form cancel
@values -min 2 -max -1
cancel -type literal(cancel)
ids -type string -multiple 1
@form -form idle
@values -min 2 -max -1
idle -type literal(idle)
scripts -type string -multiple 1
}
test cmdhelp_multiform_autoselected_form_presented {cmdhelp with argument words matching a later form presents that form: parse-status reports it and its argument table renders}\
-setup $common -body {
set d [punk::ns::cmdhelp -return dict ::testspace::formfix cancel someid]
lappend result [dict get $d parsestatus ok] [dict get $d parsestatus form]
set out [punk::ansi::ansistrip [punk::ns::cmdhelp -return string ::testspace::formfix cancel someid]]
#the argument-table rows (TYPE: lines) are the cancel form's (cancel, ids)
#not the default form's (ms, script) - the synopsis block lists all forms
set tablerows [list]
foreach ln [lines_with $out TYPE:] {
if {[regexp {^(\S+)} [punk::ansi::ansistrip $ln] -> word]} {
#-multiple arguments render with a trailing ellipsis (ids...)
lappend tablerows [string trimright $word .]
}
}
lappend result [expr {"ids" in $tablerows}] [expr {"ms" ni $tablerows}]
}\
-cleanup {
}\
-result [list 1 cancel 1 1]
test cmdhelp_multiform_noformmatch_best_candidate {cmdhelp with words matching no form completely presents the best candidate form's table under a message naming every candidate}\
-setup $common -body {
set d [punk::ns::cmdhelp -return dict ::testspace::formfix cancel]
lappend result [dict get $d parsestatus ok] [dict get $d parsestatus status]
lappend result [dict get $d parsestatus failureclass] [dict get $d parsestatus form]
set out [punk::ansi::ansistrip [punk::ns::cmdhelp -return string ::testspace::formfix cancel]]
lappend result [string match "*No form of the command matches*" $out]
foreach fname {ms cancel idle} {
lappend result [string match "*form '$fname':*" $out]
}
#the best candidate (cancel) form's argument-table rows render
set tablerows [list]
foreach ln [lines_with $out TYPE:] {
if {[regexp {^(\S+)} [punk::ansi::ansistrip $ln] -> word]} {
#-multiple arguments render with a trailing ellipsis (ids...)
lappend tablerows [string trimright $word .]
}
}
lappend result [expr {"ids" in $tablerows && "ms" ni $tablerows}]
set result
}\
-cleanup {
}\
-result [list 0 incomplete noformmatch cancel 1 1 1 1 1]
test synopsis_multiform_marks_matching_form {punk::ns::synopsis underlines the synopsis line of the form the trailing words match}\
-setup $common -body {
set syn [punk::ns::synopsis ::testspace::formfix cancel someid]
foreach line [split $syn \n] {
set plainline [punk::ansi::ansistrip $line]
if {[string match "#*" $plainline] || ![string length $plainline]} {
continue
}
set marked [expr {[string first "\x1b\[4m" $line] >= 0}]
foreach formword {ms cancel idle} {
if {[string match "* $formword *" " [string map {\[ { } \] { }} $plainline] "]} {
lappend result "$formword=$marked"
break
}
}
}
set result
}\
-cleanup {
}\
-result [list ms=0 cancel=1 idle=0]
test synopsis_multiform_unmarked_without_args {punk::ns::synopsis leaves all form lines unmarked when no trailing words are supplied}\
-setup $common -body {
set syn [punk::ns::synopsis ::testspace::formfix]
lappend result [expr {[string first "\x1b\[4m" $syn] >= 0}]
}\
-cleanup {
}\
-result [list 0]
}
tcltest::cleanupTests ;#needed to produce test summary line.

72
src/vfs/_vfscommon.vfs/modules/punk/args-0.5.0.tm → src/vfs/_vfscommon.vfs/modules/punk/args-0.6.0.tm

@ -8,7 +8,7 @@
# (C) 2024
#
# @@ Meta Begin
# Application punk::args 0.5.0
# Application punk::args 0.6.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
@ -18,7 +18,7 @@
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::args 0 0.5.0]
#[manpage_begin punkshell_module_punk::args 0 0.6.0]
#[copyright "2024"]
#[titledesc {args parsing}] [comment {-- Name section and table of contents description --}]
#[moddesc {args to nested dict of opts and values}] [comment {-- Description at end of page heading --}]
@ -905,6 +905,13 @@ tcl::namespace::eval punk::args {
-choices {<choicelist>}
A list of allowable values for an argument.
The -default value doesn't have to be in the list.
Synopsis display: a choice set of 1 to 3 members (counting
-choicegroups members) with -choicerestricted true displays
in the synopsis as unitalicised literal alternates
(e.g cancel, or left|centre|right) in the same style as
literal()/literalprefix() type-alternatives. Larger or
unrestricted choice sets display as the italicised argument
name or type, and a -typesynopsis always takes precedence.
If a -type is specified - it doesn't apply to choice members.
It will only be used for validation if the -choicerestricted
option is set to false. If all choices are specified in values
@ -11598,6 +11605,34 @@ tcl::namespace::eval punk::args {
}
proc private::synopsis_choice_literals {arginfo} {
#Small restricted choice sets display in synopses as unitalicised literal alternates,
#matching the display style of literal()/literalprefix() type-alternatives.
#Returns a list of list-protected choice words, or an empty list when the rule doesn't apply:
# - more than 3 choices (reader should refer to the full help for the choice list)
# - -choicerestricted 0 (other values are also acceptable - bare literals would be misleading)
#A defined -typesynopsis always takes precedence - callers consult it before this.
set allchoices [list]
if {[dict exists $arginfo -choices]} {
set allchoices [dict get $arginfo -choices]
}
foreach {groupname members} [Dict_getdef $arginfo -choicegroups {}] {
lappend allchoices {*}$members
}
set allchoices [punk::args::lib::lunique $allchoices]
if {[llength $allchoices] == 0 || [llength $allchoices] > 3} {
return [list]
}
if {![Dict_getdef $arginfo -choicerestricted 1]} {
return [list]
}
set literals [list]
foreach c $allchoices {
lappend literals [list $c]
}
return $literals
}
proc private::synopsis_form_arg_display {formdict argname} {
#non-colour SGR such as bold/italic/strike - so we don't need to worry about NOCOLOR settings
set I "\x1b\[3m" ;#[punk::ansi::a+ italic]
@ -11624,6 +11659,12 @@ tcl::namespace::eval punk::args {
} else {
set tp_displaylist [lrepeat [llength $typelist] ""]
}
if {[llength $typelist] == 1} {
set choice_literals [private::synopsis_choice_literals $arginfo]
} else {
#choice semantics for multi-element clauses are per-clause - keep name/type display hints
set choice_literals [list]
}
foreach typespec $typelist td $tp_displaylist elementname $name_tail {
#elementname will commonly be empty after first iteration
@ -11663,7 +11704,9 @@ tcl::namespace::eval punk::args {
#and if there are enough tail words in the argname to match the position in the type list
#empty strings can be put in -typesynopsis positions to only override the type information for certain elements of the clause
#- e.g for a type list of {string int} we could specify a typesynopsis of {"" "count"} to get display of "FILENAME count" for an argname of "file FILENAME FILECOUNT"
if {[llength $name_tail] >= [llength $typelist]} {
if {[llength $choice_literals]} {
lappend alternates {*}$choice_literals
} elseif {[llength $name_tail] >= [llength $typelist]} {
#important to list protect $elementname e.g look at ::apply
#The name may contain spaces e.g "{args body ?namespace?}"
#This must not be split into multiple words - it is a single element name that happens to contain spaces.
@ -11696,13 +11739,6 @@ tcl::namespace::eval punk::args {
set display "\[$clause\]..."
} else {
set display "\[$clause\]"
#if {[dict exists $arginfo -choices] && [llength [dict get $arginfo -choices]] == 1} {
# set display "?[lindex [dict get $arginfo -choices] 0]?"
#} elseif {[dict get $arginfo -type] eq "literal"} {
# set display "?$argname?"
#} else {
# set display "?$I$argname$NI?"
#}
}
} else {
if {[dict get $arginfo -multiple]} {
@ -11710,13 +11746,6 @@ tcl::namespace::eval punk::args {
set display "$clause \[$clause\]..."
} else {
set display $clause
#if {[dict exists $arginfo -choices] && [llength [dict get $arginfo -choices]] == 1} {
# set display "[lindex [dict get $arginfo -choices] 0]"
#} elseif {[dict get $arginfo -type] eq "literal"} {
# set display $argname
#} else {
# set display "$I$argname$NI"
#}
}
}
return $display
@ -11885,6 +11914,7 @@ tcl::namespace::eval punk::args {
} else {
set alternates [list];#alternate acceptable types e.g literal(yes)|literal(ok) or indexpression|literal(first)
set choice_literals [private::synopsis_choice_literals $arginfo]
set type_alternatives [private::split_type_expression $tp]
foreach tp_alternative $type_alternatives {
set match [lindex $tp_alternative 1]
@ -11902,7 +11932,11 @@ tcl::namespace::eval punk::args {
lappend alternates [list *$match]
}
default {
lappend alternates $I<$tp_alternative>$NI
if {[llength $choice_literals]} {
lappend alternates {*}$choice_literals
} else {
lappend alternates $I<$tp_alternative>$NI
}
}
}
}
@ -13670,7 +13704,7 @@ package provide punk::args [tcl::namespace::eval punk::args {
tcl::namespace::path {::punk::args::lib ::punk::args::system}
variable pkg punk::args
variable version
set version 0.5.0
set version 0.6.0
}]
return

85
src/vfs/_vfscommon.vfs/modules/punk/repl-0.4.0.tm → src/vfs/_vfscommon.vfs/modules/punk/repl-0.5.0.tm

@ -629,12 +629,36 @@ proc repl::start {args} {
#catch {
# set punk::console::tabwidth [punk::console::get_tabstop_apparent_width]
#}
#dead-console watchdog (G-039): the Tcl 9 windows console driver never delivers a dead
#console (killed conhost/terminal) to the script level as a fileevent, and its reader
#thread busy-loops on the persistent error - an orphaned shell would spin CPU forever.
#Poll liveness on the process console so the repl can finish via the normal eof path.
#Armed only for a tcl9 console channel (-inputmode present) on the process-default
#console; piped/foreign/8.6 inputs are unaffected.
variable console_watchdog_afterids
variable console_watchdog_ms
set watchdog_chan ""
if {"windows" eq $::tcl_platform(platform) && [console_is_default]
&& ![info exists console_watchdog_afterids($inchan)]} {
if {![catch {chan configure $inchan} wdconf] && [dict exists $wdconf -inputmode]} {
set watchdog_chan $inchan
set console_watchdog_afterids($inchan) [after $console_watchdog_ms [list [namespace current]::console_watchdog $inchan]]
}
}
vwait [namespace current]::done
if {$watchdog_chan ne "" && [info exists console_watchdog_afterids($watchdog_chan)]} {
after cancel $console_watchdog_afterids($watchdog_chan)
array unset console_watchdog_afterids $watchdog_chan
}
#done can be set before the deferred reader registration above has run (e.g exit/quit message from codethread arriving first)
#in which case the pending idle callback would re-register a readable handler on a repl that has finished
# - it must be cancelled as well as clearing any active registration.
after cancel $reader_registration_id
chan event $inchan readable {}
if {$inchan in [chan names]} {
#the console_watchdog closes a dead-console inchan before setting done
chan event $inchan readable {}
}
#puts stderr "-->start done = $::repl::done"
@ -933,6 +957,63 @@ proc repl::console_at_eof {inputchan} {
return [chan eof $inputchan]
}
namespace eval repl {
#dead-console watchdog state (G-039) - see repl::console_watchdog
variable console_watchdog_ms 5000
variable console_watchdog_afterids
array set console_watchdog_afterids {}
}
namespace eval repl::argdoc {
lappend PUNKARGS [list {
@id -id ::repl::console_watchdog
@cmd -name "repl::console_watchdog"\
-summary\
"Self-rescheduling liveness poll for the repl's process-console input channel."\
-help\
"Probes the repl input channel's console with 'chan configure -inputmode'
(a live GetConsoleMode call on windows) every repl::console_watchdog_ms
milliseconds. If the probe fails the hosting console is gone (e.g the
terminal or conhost was killed): the Tcl 9 windows console driver never
delivers that state to the script level as a fileevent (its ConsoleEventProc
only notifies on buffered data) and its reader thread busy-loops on the
persistent channel error, so an orphaned shell would otherwise spin CPU
indefinitely (G-039). On a failed probe the watchdog closes the input
channel (which lets the driver's reader thread exit, stopping the spin)
and finishes the repl via the normal eof done-path.
Armed by repl::start only for a tcl9 console channel (-inputmode present
in the chan configure dict) serving the process-default console on
windows. Scheduling state is kept per channel name in
repl::console_watchdog_afterids; a watchdog whose channel has
disappeared disarms itself silently."
@values -min 1 -max 1
inputchan -type string -help\
"the repl input channel being watched"
}]
}
proc repl::console_watchdog {inputchan} {
variable console_watchdog_afterids
variable console_watchdog_ms
if {$inputchan ni [chan names]} {
array unset console_watchdog_afterids $inputchan
return
}
if {[catch {chan configure $inputchan -inputmode}]} {
#GetConsoleMode failed - the hosting console is gone.
#Close the channel (stops the tclWinConsole.c reader thread's error busy-loop)
#and finish the repl via the normal eof path. Do not attempt a console reopen:
#with the console dead, CONIN$ cannot be opened either (app-punkshell's eof
#handling makes the same discovery and exits cleanly).
array unset console_watchdog_afterids $inputchan
catch {chan event $inputchan readable {}}
catch {chan close $inputchan}
catch {puts stderr "|repl> console_watchdog: console unavailable for '$inputchan' - closing input and finishing repl (eof)"}
set ::repl::done [list eof $inputchan]
return
}
set console_watchdog_afterids($inputchan) [after $console_watchdog_ms [list [namespace current]::console_watchdog $inputchan]]
}
namespace eval repl::argdoc {
lappend PUNKARGS [list {
@id -id ::repl::console_get_size
@ -4437,7 +4518,7 @@ namespace eval ::punk::args::register {
package provide punk::repl [namespace eval punk::repl {
variable version
set version 0.4.0
set version 0.5.0
}]
#repl::start $program_read_stdin_pipe
Loading…
Cancel
Save