Compare commits

...

14 Commits

Author SHA1 Message Date
Julian Noble 22ff5cf802 goals: G-078..G-081 - ansi->html api, default-colour semantics, cell-grid mode, argdoc build pipeline 2 weeks ago
Julian Noble 5df5f65b4f punknative generator: nav index, per-form arg tables, terminal-black backdrop, integer row pitch 2 weeks ago
Julian Noble 60dd6094c7 fontprep/punknative: settle ansi-art html rendering recipe; reproducible font builds; revert metric-normalization experiment 2 weeks ago
Julian Noble c457d4222a fontprep: convert vendored Cascadia OFL licence text to LF line endings 2 weeks ago
Julian Noble c18ca90a70 doc tooling: vendor punkdoc-mono web font (OFL subset of Cascadia Mono 2404.23) + -assets embed|link|none in punknative generator 2 weeks ago
Julian Noble c668cb85c1 doc tooling: punk::args -> doctools + punk-native html/md converter prototypes 2 weeks ago
Julian Noble 49ec22a928 doc: restore 'dev doc.validate' - wrap tcllib dtplite as bin/dtplite.cmd 2 weeks ago
Julian Noble 4e23a2c373 vfs: sync punk::args 0.12.0 and tclcore moduledoc 0.3.4 into _vfscommon.vfs (G-074) 2 weeks ago
Julian Noble 6af0fd541e punk::args 0.12.0: formcheck on-demand multiform ambiguity analysis + @form -overlapallowed sanction (G-074 achieved) 2 weeks ago
Julian Noble ce5108cb09 DOX: ignore-rule changes in one VCS must be mirrored in the other 2 weeks ago
Julian Noble 5da307ebe2 goals: add G-077 (punk executable -e one-liner support) 2 weeks ago
Julian Noble 06db38be46 agent guardrails: tclsh has no -e one-liner flag - AGENTS.md pitfall entry + claude PreToolUse deny hook 2 weeks ago
Julian Noble 7c49fc844e vfs: sync punk::args 0.11.2, punk::ns 0.5.0, punk::lib 0.4.3, punk 0.2.6, punk::repl 0.5.1, tclcore moduledoc 0.3.3 into _vfscommon.vfs 2 weeks ago
Julian Noble 97ca1ceb1e punk::lib 0.4.3 + punk::repl 0.5.1 + punk 0.2.6: G-076 dead-console defect in 'help tcl' with mitigated axis, version-gated watchdog (project 0.12.21) 2 weeks ago
  1. 50
      .claude/settings.json
  2. 6
      .fossil-settings/AGENTS.md
  3. 3
      .fossil-settings/ignore-glob
  4. 4
      .gitignore
  5. 2
      AGENTS.md
  6. 8
      CHANGELOG.md
  7. 4
      GOALS-archive.md
  8. 28
      GOALS.md
  9. 14
      bin/AGENTS.md
  10. 1661
      bin/dtplite.cmd
  11. 16
      goals/G-055-tclcore-regen-workflow.md
  12. 89
      goals/G-076-tcl9-deadconsole-fix-adoption.md
  13. 21
      goals/G-077-punkexe-dash-e-oneliner.md
  14. 20
      goals/G-078-punkansi-tohtml-api.md
  15. 17
      goals/G-079-renderspace-default-colour-semantics.md
  16. 18
      goals/G-080-ansi2html-cellgrid-mode.md
  17. 18
      goals/G-081-argdoc-build-pipeline.md
  18. 72
      goals/archive/G-074-punkargs-multiform-ambiguity-lint.md
  19. 2
      punkproject.toml
  20. 27
      src/modules/punk-999999.0a1.0.tm
  21. 3
      src/modules/punk-buildversion.txt
  22. 2
      src/modules/punk/AGENTS.md
  23. 476
      src/modules/punk/args-999999.0a1.0.tm
  24. 3
      src/modules/punk/args-buildversion.txt
  25. 6
      src/modules/punk/args/moduledoc/tclcore-999999.0a1.0.tm
  26. 3
      src/modules/punk/args/moduledoc/tclcore-buildversion.txt
  27. 57
      src/modules/punk/lib-999999.0a1.0.tm
  28. 4
      src/modules/punk/lib-buildversion.txt
  29. 13
      src/modules/punk/repl-999999.0a1.0.tm
  30. 3
      src/modules/punk/repl-buildversion.txt
  31. 2
      src/scriptapps/AGENTS.md
  32. 17
      src/scriptapps/dtplite.tcl
  33. 15
      src/scriptapps/dtplite_wrap.toml
  34. 94
      src/scriptapps/tools/fontprep/LICENSE-cascadia-OFL.txt
  35. 87
      src/scriptapps/tools/fontprep/README.md
  36. 145
      src/scriptapps/tools/fontprep/make_punkdoc_mono.py
  37. BIN
      src/scriptapps/tools/fontprep/punkdoc-mono.woff2
  38. 21
      src/scriptapps/tools/fontprep/punkdoc-unicodes.txt
  39. 556
      src/scriptapps/tools/punkargs_punknative.tcl
  40. 318
      src/scriptapps/tools/punkargs_to_doctools.tcl
  41. 195
      src/tests/modules/punk/args/testsuites/args/formcheck.test
  42. 43
      src/tests/modules/punk/lib/testsuites/lib/checkbugs.test
  43. 231
      src/tests/shell/testsuites/binscripts/dtplite.test
  44. 47
      src/vfs/_vfscommon.vfs/modules/punk-0.2.6.tm
  45. 1337
      src/vfs/_vfscommon.vfs/modules/punk/args-0.12.0.tm
  46. 62
      src/vfs/_vfscommon.vfs/modules/punk/args/moduledoc/tclcore-0.3.4.tm
  47. 75
      src/vfs/_vfscommon.vfs/modules/punk/lib-0.4.3.tm
  48. 162
      src/vfs/_vfscommon.vfs/modules/punk/ns-0.5.0.tm
  49. 15
      src/vfs/_vfscommon.vfs/modules/punk/repl-0.5.1.tm

50
.claude/settings.json

@ -0,0 +1,50 @@
{
"permissions": {
"allow": [
"Bash(tclsh90 src/tests/runtests.tcl *)",
"Bash(tclsh src/tests/runtests.tcl *)",
"Bash(/c/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh90 src/tests/runtests.tcl *)",
"Bash(\"C:/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh.exe\" src/tests/runtests.tcl *)",
"Bash(./bin/punk91.exe src/tests/runtests.tcl *)",
"Bash(tclsh src/make.tcl *)",
"Bash(tclsh90 src/make.tcl *)",
"Bash(tclsh87 src/make.tcl *)",
"Bash(/c/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh90 src/make.tcl *)",
"Bash(\"C:/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh.exe\" src/make.tcl *)",
"Bash(./bin/punk91.exe src/make.tcl *)",
"Bash(./bin/punk902z.exe src/make.tcl *)",
"Bash(./bin/punksys.exe src/make.tcl *)",
"Bash(tclsh scriptlib/developer/goals_lint.tcl *)",
"Bash(tclsh90 scriptlib/developer/goals_lint.tcl *)",
"Bash(/c/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh90 scriptlib/developer/goals_lint.tcl *)",
"Bash(\"C:/Users/sleek/AppData/Local/Apps/Tcl903/bin/tclsh.exe\" scriptlib/developer/goals_lint.tcl *)",
"PowerShell(tclsh scriptlib/developer/goals_lint.tcl *)",
"PowerShell(tclsh90 scriptlib/developer/goals_lint.tcl *)",
"PowerShell(& \"C:\\Users\\sleek\\AppData\\Local\\Apps\\Tcl903\\bin\\tclsh90.exe\" scriptlib/developer/goals_lint.tcl *)",
"PowerShell(tclsh src/make.tcl *)",
"PowerShell(tclsh90 src/make.tcl *)",
"PowerShell(& \"C:\\Users\\sleek\\AppData\\Local\\Apps\\Tcl903\\bin\\tclsh90.exe\" src/make.tcl *)",
"PowerShell(& c:\\repo\\jn\\shellspy\\bin\\punk902z.exe src/make.tcl *)",
"PowerShell(Get-Process *)",
"PowerShell(git status *)",
"PowerShell(git log *)",
"PowerShell(git diff *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "cmd=$(jq -r '.tool_input.command // empty'); if printf '%s' \"$cmd\" | grep -qiE '(^|[|;&(]|\\$\\()[[:space:]]*[^|;&[:space:]]*tclsh[0-9.]*(\\.exe)?\"?[[:space:]]+(-encoding[[:space:]]+[^[:space:]]+[[:space:]]+)?-e([[:space:]]|$)'; then printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"tclsh has no -e one-liner flag. An argument starting with - is not treated as a script file: all args land in $argv and tclsh reads commands from stdin instead (hangs forever at an interactive prompt on a console; silently ignores the -e script when stdin is piped). Write the code to a temp .tcl file and run: tclsh path/to/file.tcl - or pipe the script via stdin. See AGENTS.md User Preferences.\"}}'; fi",
"shell": "bash",
"timeout": 15,
"statusMessage": "Checking for tclsh -e misuse"
}
]
}
]
}
}

6
.fossil-settings/AGENTS.md

@ -24,13 +24,15 @@ Versioned fossil settings (one setting value or glob list per file) for this dua
### ignore-glob derivation rules (verified against fossil 2.28)
- **No negation exists in fossil globs.** Git's `/bin/*` + `!exception` pattern becomes: ignore `bin/*` wholesale and explicitly `fossil add` the tracked exceptions (ignore-glob never affects already-managed files, so they stay tracked). The exception set is whatever git tracks under an ignored tree - at the time of writing: `bin/AGENTS.md`, `bin/*.cmd`, `bin/*.kit`, `bin/*.tcl`, `bin/*.sh`, `bin/*.bash`, plus force-tracked one-offs (`bin/libssp-0.dll`, `src/vfs/punk9magicsplat.vfs/lib/nmake/x86_64-w64-mingw32-nmakehlp.exe`). Derive the current set with the verification comparison below rather than trusting this list.
- **No negation exists in fossil globs.** Git's `/bin/*` + `!exception` pattern becomes: ignore `bin/*` wholesale and explicitly `fossil add` the tracked exceptions (ignore-glob never affects already-managed files, so they stay tracked). The exception set is whatever git tracks under an ignored tree - at the time of writing: `bin/AGENTS.md`, `bin/*.cmd`, `bin/*.kit`, `bin/*.tcl`, `bin/*.sh`, `bin/*.bash`, `.claude/settings.json` (shared claude harness hooks/permissions; session-local `.claude` files stay ignored), plus force-tracked one-offs (`bin/libssp-0.dll`, `src/vfs/punk9magicsplat.vfs/lib/nmake/x86_64-w64-mingw32-nmakehlp.exe`). Derive the current set with the verification comparison below rather than trusting this list.
- A bare directory name prunes that whole tree; `*` crosses `/`; git patterns intended to match at any depth need an additional `*/` variant; `#` comment lines are honoured.
- Do not add the fossil-generated names (`manifest`, `manifest.uuid`, `manifest.tags`) to `ignore-glob` - fossil handles them itself.
- **Nested `.gitignore` files are git-only**: ignore files inside subtrees (e.g. the project-layout templates under `src/project_layouts/`) are honoured by git as nested ignores of the outer repo but invisible to fossil - template content they match is untracked in git yet managed by fossil. Treat such divergence as a signal to review the git side (usually `git add -f` of the affected template files).
- **Case sensitivity differs**: git ignore matching is case-insensitive on Windows (`core.ignorecase`), fossil glob matching is case-sensitive - e.g. a `todo.txt` pattern hits vendored `TODO.txt` in git but not fossil.
### Safe sync procedure (when asked to "sync the ignores")
### Safe sync procedure (mandatory for any ignore-rule change, not only on request)
Ignore-rule changes never land one-sided: any agent edit to `.gitignore` includes the hand-derived `ignore-glob` translation (plus any explicit `fossil add` of negation exceptions) in the same work unit, and vice versa (root `AGENTS.md` User Preferences carries the pointer to this rule).
1. Edit `.gitignore` first (canonical), then hand-translate to `ignore-glob` using the rules above. Never translate a negation literally.
2. Verify from the checkout root:

3
.fossil-settings/ignore-glob

@ -22,7 +22,8 @@ tmp
.vscode
*/.vscode
.claude
#claude harness config: shared .claude/settings.json is a tracked exception by explicit add (see header)
.claude/*
*/.claude
.omo
*/.omo

4
.gitignore vendored

@ -23,7 +23,9 @@
/tmp/
.vscode
.claude
#claude harness config: track the shared project settings (hooks/permissions), ignore everything else (session-local settings etc)
.claude/*
!.claude/settings.json
.omo
/logs/

2
AGENTS.md

@ -95,6 +95,8 @@ When the user requests a durable behavior change, record it here or in the relev
- 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.
- Tcl has no `-e`/`-c` one-liner flag (a reflex agents carry over from perl/python/node). Stock `tclsh`/`tclsh86`/`tclsh90` recognise only `-encoding name` as a leading option; any other argument starting with `-` is NOT treated as a script file - all arguments land in `$argv` and tclsh reads commands from stdin. On a console that hangs forever at an interactive prompt; with piped/redirected stdin it exits 0 having silently ignored the supposed one-liner and executed stdin instead. (The punk kits differ: they treat `-e` as a script filename and error out immediately - no hang, but still no one-liner.) To run ad-hoc Tcl: write a temp `.tcl` file and run `tclsh path/to/file.tcl`, or pipe the script to stdin (`echo 'puts hi' | tclsh`, or a bash heredoc). Defensive habit regardless: when exec'ing tclsh non-interactively, redirect stdin (`</dev/null`, `< NUL`) so a mis-invocation exits at EOF instead of hanging.
- VCS ignore rules are dual-tracked (git + fossil). An agent that changes ignore rules in one VCS must make the equivalent change in the other in the same work unit: `.gitignore` is the canonical statement of intent and `.fossil-settings/ignore-glob` is hand-derived from it, never the reverse - so a `.gitignore` edit includes deriving the ignore-glob translation, and an ignore-glob-only edit is wrong unless it is purely catching up to `.gitignore`. Fossil globs differ semantically (no negation - tracked exceptions need explicit `fossil add`; case-sensitive; `*` crosses `/`): translate per the derivation rules in `.fossil-settings/AGENTS.md` and run its verification checks before committing.
- 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.

8
CHANGELOG.md

@ -5,6 +5,14 @@ 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.22] - 2026-07-13
- G-074 (achieved): punk::args 0.12.0 adds punk::args::formcheck - on-demand multiform ambiguity analysis. It reports the form pairs of a definition that some argument list could cleanly match simultaneously, confirming every candidate with a real parse of a synthetic witness arglist against both forms - so fully discriminated form pairs can never false-alarm, and each reported witness is a genuine multipleformmatches input. Findings classify as type-weakness (a literal/choice discriminator aligned with a permissive non-validating type such as expr/any/string - the 'lseq 1 count 5' class) vs structural (the forms genuinely share an argument shape - the 'after cancel id|script' class). The new @form -overlapallowed <formname-list> key sanctions a known/documented overlap for formcheck reporting only (parse behaviour untouched; unknown form names rejected at definition resolve); tclcore moduledoc 0.3.4 adopts it for the after cancel pair, leaving ::after with zero unsanctioned findings while ::lseq's expr-typed-end findings stay visible as actionable. New testsuite args/formcheck.test.
## [0.12.21] - 2026-07-13
- G-076 (new goal, active): 'help tcl' now warns about the tcl9 dead-console defect (upstream ticket f10d91c2d3, root-caused in G-039: a dead console is never delivered to the script as a fileevent while the core's console reader thread busy-loops) via punk::lib 0.4.3's has_tclbug_console_deadspin — version-based detection through the pure classifier tclbug_console_deadspin_applies, gated by check::tclbug_console_deadspin_fixed_in (empty until a released Tcl contains the upstream fix verified by re-running the G-039 kill procedure). punk::repl 0.5.1 arms the dead-console watchdog only when that same check reports the runtime affected, so recording the fixed release once silences the warning and stops arming the watchdog together. Buginfo dicts gain a mitigated/mitigation axis orthogonal to level (punk 0.2.6 renders it): the deadspin warning keeps severity major but displays "(mitigated)" in subdued grey with the watchdog's scope described when punk::repl >= 0.5.0 is available to the runtime; non-repl console reads remain exposed and unmitigated warnings render unchanged.
## [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.

4
GOALS-archive.md

@ -84,3 +84,7 @@ Acceptance: LICENSE.txt exists at the repo root containing the standard BSD-2-Cl
### 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.
### G-074 [achieved 2026-07-13] punk::args multiform ambiguity analysis: on-demand form-overlap detection with sanctioned-overlap annotation → detail: goals/archive/G-074-punkargs-multiform-ambiguity-lint.md
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)
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.

28
GOALS.md

@ -286,10 +286,30 @@ Detail: goals/G-072-punkargs-compound-clause-types.md
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
### G-076 [active] Adopt upstream tcl9 dead-console fix: shared version gate for watchdog and help tcl warning
Scope: src/modules/punk/lib-999999.0a1.0.tm (punk::lib::check), src/modules/punk/repl-999999.0a1.0.tm (repl::start watchdog arming), src/modules/punk/AGENTS.md, src/tests/modules/punk/lib/testsuites/lib/checkbugs.test
Detail: goals/G-076-tcl9-deadconsole-fix-adoption.md
### G-077 [proposed] punk executable -e one-liner support: make the tclsh-agent instinct work
Scope: src/vfs/_config/punk_main.tcl (top-level arg dispatch), src/lib/app-punkscript/punkscript.tcl (script subcommand arg forms), src/tests/shell/testsuites/punkexe/scriptexec.test
Detail: goals/G-077-punkexe-dash-e-oneliner.md
### G-078 [proposed] punk::ansi ANSI->HTML rendering api (developer-surfaced, documented)
Scope: src/modules/punk/ansi-999999.0a1.0.tm (or new punk::ansi::html module), src/scriptapps/tools prototypes as reference implementations, src/scriptapps/tools/fontprep/ (asset + cell-geometry contract), new testsuite under src/tests/modules/punk/ansi/
Detail: goals/G-078-punkansi-tohtml-api.md
### G-079 [proposed] default-colour semantics through renderspace/flattened output
Scope: src/modules/overtype-999999.0a1.0.tm (renderspace/renderline replay-code emission), punk::ansi flatten consumers (ansicat), ansi2html consumer mapping (G-078)
Detail: goals/G-079-renderspace-default-colour-semantics.md
### G-080 [proposed] deterministic cell-grid html rendering mode (seamless block art)
Scope: the G-078 punk::ansi html api (additional rendering mode); scratch prototypes as baseline
Detail: goals/G-080-ansi2html-cellgrid-mode.md
### G-081 [proposed] punk::args documentation build pipeline (dev doc.* integration)
Scope: src/modules/punk/mix/commandset/doc-999999.0a1.0.tm (doc.* generation subcommands), src/scriptapps/tools/punkargs_to_doctools.tcl + punkargs_punknative.tcl (productize), src/scriptapps/tools/fontprep/ (shared doc-set asset), src/doc tree conventions, src/tests coverage
Detail: goals/G-081-argdoc-build-pipeline.md

14
bin/AGENTS.md

@ -9,7 +9,8 @@ Built punk shell executables (kits with the punk boot layer), assorted build/exp
The `.cmd` scripts here (e.g `runtime.cmd`) are punk MULTISHELL polyglots GENERATED by
`punk::mix::commandset::scriptwrap::multishell` from scriptset sources under
`src/scriptapps/bin/` (the home for bin-deployed scriptsets, e.g `runtime.*` alongside
`getzig.*`): payload scripts (`<name>.ps1`, `<name>.bash`, ...) plus a `<name>_wrap.toml`
`getzig.*`) or `src/scriptapps/` itself (e.g `tclargs.*`, `dtplite.*`): payload scripts
(`<name>.ps1`, `<name>.bash`, `<name>.tcl`, ...) plus a `<name>_wrap.toml`
config, spliced into the `punk.multishell.cmd` template. The polyglot structure is
deliberately fragile (mutual shell-hiding tricks, LF-only endings, cmd.exe's 512-byte
label-scanner constraints) - a hand-edit can silently break one of the participating
@ -31,6 +32,17 @@ When asked to "fix bin/<name>.cmd":
re-wrap of the runtime scriptset reproduces `bin/runtime.cmd` byte for byte
(`src/tests/modules/punk/mix/testsuites/scriptwrap/multishell.test`
scriptwrap_runtime_cmd_roundtrip_no_drift), so drift in either direction fails tests.
`bin/dtplite.cmd` is pinned the same way
(`src/tests/shell/testsuites/binscripts/dtplite.test` dtplite_cmd_roundtrip_no_drift).
### dtplite (`dtplite.cmd`)
`bin/dtplite.cmd` wraps tcllib's dtplite doctools processor (payload
`src/scriptapps/dtplite.tcl`, tclsh nextshell on all platforms). It exists so the punk
repl's unknown-handler can resolve the bare `dtplite` command via PATH - `dev
doc.validate` (punk::mix::commandset::doc) depends on it. The payload falls back to the
project-vendored tcllib (`src/vendorlib_tcl9/<arch>/tcllib*`) when the invoking tclsh
has no dtplite package installed.
## Local Contracts

1661
bin/dtplite.cmd

File diff suppressed because it is too large Load Diff

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

@ -60,6 +60,16 @@ Per command:
flag-lookalike shapes) run against the real command and parse_status, asserting
error-vs-ok agreement; committed as a parity test deriving expectations from
the live interpreter, not version arithmetic.
Additionally (G-074 achieved 2026-07-13 -
goals/archive/G-074-punkargs-multiform-ambiguity-lint.md): run
punk::args::formcheck on every
regenerated MULTIFORM command - new UNSANCTIONED findings block acceptance
(a type_weakness finding means a discriminator faces a permissive type and the
forms need tightening or the ambiguity is real; a genuine documented overlap is
sanctioned with @form -overlapallowed, as adopted for ::after cancel). The
result dict's 'unsanctioned' key is the gate value (must be empty). Known
pre-existing exception: ::lseq's range/start_count and range/count findings
stay unsanctioned pending an expr syntax-validating type (G-069/G-070).
4. Record provenance: the source checkin id the text was read from, noted with the
definition (comment) and in the module changelog.
@ -146,8 +156,10 @@ other commands including the multi-form ::after.
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):
G-074 sanction (shipped 2026-07-13 as @form -overlapallowed, adopted on the
cancelid form in tclcore 0.3.4) acknowledges the cancelid/cancelscript pair
for formcheck reporting - thanks to the id typing, the only witnesses the
pair still shares ARE the id-shaped words. 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

89
goals/G-076-tcl9-deadconsole-fix-adoption.md

@ -0,0 +1,89 @@
# G-076 Adopt upstream tcl9 dead-console fix: shared version gate for watchdog and help tcl warning
Status: active
Scope: src/modules/punk/lib-999999.0a1.0.tm (punk::lib::check), src/modules/punk/repl-999999.0a1.0.tm (repl::start watchdog arming), src/modules/punk/AGENTS.md, src/tests/modules/punk/lib/testsuites/lib/checkbugs.test
Goal: the tcl9 dead-console defect (upstream ticket f10d91c2d3, root-caused in G-039) is surfaced by 'help tcl' via a has_tclbug_* check, and that check and the repl dead-console watchdog arming share a single version gate, so that runtimes at or past the first released Tcl containing the verified upstream fix neither warn nor arm the watchdog, while all earlier Tcl 9 windows runtimes keep both.
Acceptance: 'help tcl' on an affected runtime shows the warning with the f10d91c2d3 link and mitigation note; the applicability decision is a pure facts-in/verdict-out classifier proc with tests (libbug_udp_threadexit_applies precedent); repl::start arms the watchdog only when the same classifier reports the runtime affected, with live-console/piped/8.6 behaviour unchanged; the fixed-in constant is set only after the G-039 kill procedure, re-run on a released runtime containing the fix with the watchdog disabled, shows the repl exiting cleanly via the script-visible eof path - until then every Tcl 9 windows runtime is treated as affected.
## Context
G-039 (achieved 2026-07-12, goals/archive/G-039-orphan-console-spin.md) root-caused an orphaned-shell
CPU spin to a Tcl 9 core defect pair in win/tclWinConsole.c: (a) ConsoleEventProc drops the error/EOF
notification for a dead console so the script-level fileevent never fires; (b) ConsoleReaderThread
busy-loops on the persistent error. The script-level mitigation is repl::console_watchdog (punk::repl
0.5.0). The upstream ticket https://core.tcl-lang.org/tcl/tktview/f10d91c2d3 was filed 2026-07-12 and
has since been updated upstream with a fix branch in the Tcl repository (observed 2026-07-13).
This goal tracks adoption of the eventual released fix: surfacing the defect through the existing
'help tcl' has_tclbug_* auto-discovery, and making the watchdog version-conditional so runtimes
carrying the released fix don't warn and don't arm the probe.
## Approach
- punk::lib::check gains a pure classifier `tclbug_console_deadspin_applies {platform tclversion}`
(facts in, verdict out, testable - same pattern as libbug_udp_threadexit_applies from G-036, achieved -
see goals/archive/G-036-tcl9-udp-console-worker-wedge.md) and a
`has_tclbug_console_deadspin` wrapper returning the standard buginfo dict (bugref f10d91c2d3,
level major, description noting punkshell's watchdog mitigation).
- Detection is version-based only: a behavioural probe is impossible (it would require killing a
console). The classifier compares against a `tclbug_console_deadspin_fixed_in` namespace variable;
empty means "no fixed release known" and every Tcl 9 windows runtime classifies as affected.
- repl::start's watchdog arming consults the same classifier as an additional AND-term, so setting
the fixed-in constant once flips both the 'help tcl' warning and the watchdog together.
- Error direction is safe: a trunk/dev build carrying the fix under an old-looking patchlevel merely
arms a harmless 5s read-only probe.
- The fixed-in flip is deliberately gated on verification, not on the upstream branch: G-039 found
TWO defects, and the watchdog is only redundant if the notification defect (a) is fixed - i.e. the
script actually sees EOF on console death. Before setting fixed_in, re-run the G-039 kill
procedure (documented in the archived detail file) on the fixed released runtime with the watchdog
disabled and confirm the repl exits via the normal fileevent/eof path. If upstream fixes only the
spin, the watchdog stays necessary and the check's description changes instead.
## Notes
- G-039 (archived) recorded the reproduction/kill procedure and the two-defect mechanism - see
goals/archive/G-039-orphan-console-spin.md. Full ticket text with repro scripts:
TEMP_REFERENCE/tcl9-dead-console-spin-TICKET-DRAFT.md.
- 8.6 remains out of scope (different console driver; per G-039 user decision 2026-07-12).
## Progress
### 2026-07-13 classifier + help tcl surfacing + watchdog gating (initial increment)
Landed (punk::lib 0.4.2, punk::repl 0.5.1):
- punk::lib::check: `tclbug_console_deadspin_applies {platform tclversion fixed_in}` pure
classifier + `has_tclbug_console_deadspin` wrapper (bugref f10d91c2d3, level major, description
notes the punk::repl watchdog mitigation) + gate variable `tclbug_console_deadspin_fixed_in`
(empty = all Tcl 9 windows affected).
- repl::start watchdog arming gained the `[dict get [punk::lib::check::has_tclbug_console_deadspin] bug]`
AND-term; console_watchdog argdoc and src/modules/punk/AGENTS.md note updated.
- Tests: checkbugs.test +2 (classifier truth table incl. fixed_in comparisons; live-check/classifier
consistency + bugref).
Verification (2026-07-13, native tclsh 9.0.3 runtests + punk902z kit 9.0.2 in src mode):
- lib checkbugs.test 5/5 pass; repl consolebackends.test 3/3 pass; `make.tcl modules` builds clean.
- Live check on tclsh 9.0.3: bug=1; setting fixed_in to the running version flips bug=0.
- `'help tcl' | punk902z src` (piped, PUNK_PIPE_EOF=exit): major warning displayed with
description, mitigation note and f10d91c2d3 hyperlink.
### 2026-07-13 mitigated/mitigation buginfo axis (punk::lib 0.4.3, punk 0.2.6)
- buginfo dicts gain an optional axis orthogonal to level: `mitigated` boolean + `mitigation`
text. 'help tcl' renders a triggered mitigated check as "warning level: <level> (mitigated)"
in subdued grey (term-grey foreground) instead of the level colour, with an indented
"mitigated: <text>" block; unmitigated warnings render unchanged.
- has_tclbug_console_deadspin populates it: mitigated when punk::repl >= 0.5.0 is available to
the runtime (version discovery without loading, udp-check precedent), mitigation text noting
the watchdog scope (non-repl console reads remain exposed). Severity stays major.
- checkbugs.test extended (axis key presence/boolean/consistency rules incl. the generic
every-check sweep); 5/5 pass under native tclsh 9.0.3. Piped `'help tcl' | punk902z src`
verified: grey (38;5;8 fg) mitigated rendering with mitigation block and ticket hyperlink.
Remaining for acceptance:
- When a released Tcl contains the merged upstream fix: re-run the G-039 kill procedure on that
runtime with the watchdog disabled, confirm clean script-visible eof exit, then set
`punk::lib::check::tclbug_console_deadspin_fixed_in` to that version (one edit flips both
consumers) and update the AGENTS.md note.

21
goals/G-077-punkexe-dash-e-oneliner.md

@ -0,0 +1,21 @@
# G-077 punk executable -e one-liner support: make the tclsh-agent instinct work
Status: proposed
Scope: src/vfs/_config/punk_main.tcl (top-level arg dispatch), src/lib/app-punkscript/punkscript.tcl (script subcommand arg forms), src/tests/shell/testsuites/punkexe/scriptexec.test
Goal: `<punkexe> -e <script> ?args...?` and `<punkexe> script -e <script> ?args...?` execute the given Tcl code as a one-liner (args after the script land in `::argv`, `::argv0` = `-e`, non-interactive), so the -e reflex agents carry over from perl/python/node works on punk kits instead of erroring - while stock-tclsh misparse behaviour (argv-swallow + stdin read) is never reproduced.
Acceptance: `punksys -e {puts hi}` and `punk902z script -e {puts hi}` print hi and exit 0 with and without piped stdin present (the one-liner may itself read stdin); a -e with no following script argument is a usage error, never an interactive fall-through; error in the one-liner prints errorInfo to stderr and exits 1 (matching the script subcommand's file form); scriptexec.test covers the above against the built executable.
## Context
Agents and harnesses habitually try `tclsh -e "script"` (the one-liner reflex from perl/python/node).
Stock tclsh has no such flag: only `-encoding name` is recognised, and any other leading-dash
argument is not treated as a script file - all arguments land in `$argv` and tclsh reads commands
from stdin instead (hangs at an interactive prompt on a console; silently ignores the supposed
one-liner when stdin is piped). Root `AGENTS.md` User Preferences documents the pitfall and a
claude-harness PreToolUse hook (`.claude/settings.json`) denies such invocations with corrective
guidance.
The punk kits currently fail fast instead (`punk script: script file not found: '-e'`) but offer
no one-liner form. This goal makes the instinct work on punk executables, removing the failure
class for the kits entirely. app-punkscript's existing stdin form already provides the execution
scaffolding (argv0/argv setup, errorInfo-to-stderr, exit-code discipline).

20
goals/G-078-punkansi-tohtml-api.md

@ -0,0 +1,20 @@
# G-078 punk::ansi ANSI->HTML rendering api (developer-surfaced, documented)
Status: proposed
Scope: src/modules/punk/ansi-999999.0a1.0.tm (or a new punk::ansi::html module), src/scriptapps/tools/punkargs_punknative.tcl + scratch prototypes (ansi2html_core.tcl, ansicat_gallery.tcl) as reference implementations to be retired into consumers, src/scriptapps/tools/fontprep/ (asset + geometry contract), new testsuite under src/tests/modules/punk/ansi/
Goal: a documented public api converting ANSI/SGR text to html: a fragment converter (spans for a `<pre>`) plus a page assembler with `-assets embed|link|none` (punkdoc-mono data-uri / relative file / stack-only), `-palette vga|campbell|vscode|<list>` (16-colour table - classic art needs VGA where SGR 33 is brown), art mode (bold-as-bright for basic colours, no font-weight, solid-block background sealing, tight pitch + scaleY aspect correction per the settled recipe) and doc mode (text-oriented pitch), and a headless flatten entry for files/captures (ansicat semantics - cursor movement/wrap/SAUCE/cp437 - without repl/console dependencies).
Acceptance: renders the prototype corpus (src/testansi gallery samples incl. octants, textblock::periodic, punk::args::usage tables) with parity to the settled recipe; runs headless in a plain tclsh with declared package deps only (no punk::console load, no fcat/global-alias assumptions); covered by tests incl. SGR state-machine cases (reset/bold/dim/italic/underline/reverse, 16/256/truecolour, bold-as-bright) and an artifact pin of a small converted sample; the scriptapps prototypes become thin consumers of the api.
## Context
Prototyped 2026-07-13/14 (scratch/argdoc2man-trial-2026-07-13). The settled empirical
recipe and its rejected alternatives (larger row pitches, font line-metric
normalization - wrong-coloured bleed bars) are recorded in
src/scriptapps/tools/fontprep/README.md and the prototype comments; cell geometry
contract: punkdoc-mono advance exactly 0.5859375em (integer 9px cells at
font-size 15.36px), blocks paint the 1.3213em win cell, art aspect restored via
transform:scaleY(1.3212890625) over a line-height:1.0 layout.
This api is also the docs-screenshot enabler (ansicat/textblock output embedded in
generated documentation) identified in the punk::args-as-doc-source-of-truth
assessment. Related: G-079 (default-colour semantics), G-080 (deterministic
cell-grid mode), G-081 (docs build pipeline consumer).

17
goals/G-079-renderspace-default-colour-semantics.md

@ -0,0 +1,17 @@
# G-079 default-colour semantics through renderspace/flattened output
Status: proposed
Scope: src/modules/overtype-999999.0a1.0.tm (renderspace/renderline replay-code emission), punk::ansi flatten consumers (ansicat), ansi2html consumer mapping (G-078)
Goal: flattened/rendered ANSI output preserves "default foreground/background" as a semantic value (SGR 39/49 or absence) instead of baking in explicit colours (replay/overlay output currently emits literal `0;40` - black background - for cells whose background was never set). Consumers can then map defaults to their own backdrop: html pages theme-match any page background, terminal replays keep the user's colour scheme.
Acceptance: ansicat/usage flattened output for content that never sets a background contains no explicit SGR 40/black-bg codes for those cells; the punk-native html pages render example-helper top/bottom bars (e.g. grepstr @cmd -help examples) blending with a NON-black page background with no contrasting bars; existing terminal display output is visually unchanged (regression-checked against current renderings).
## Context
Found 2026-07-14 while reviewing the punk::ansi punknative doc page: example blocks
produced by the punk::ansi example helper show bars whose "background half" rendered
as black boxes contrasting with a grey page. Probe evidence: renderspace overlay
replay emits `replay_codes_overlay: ESC[0;40m`. Interim workaround (shipped in the
punknative generator): the `<pre>` backdrop is hard black so baked-black blends -
this constrains doc page theming and leaves lighter antialiasing seams at half-block
edges against non-matching content. Proper fix is upstream in the renderer's
replay-code model, then a `-defaultbg`/`-defaultfg` mapping option in the G-078 api.

18
goals/G-080-ansi2html-cellgrid-mode.md

@ -0,0 +1,18 @@
# G-080 deterministic cell-grid html rendering mode (seamless block art)
Status: proposed
Scope: the G-078 punk::ansi html api (additional rendering mode); scratch prototypes as baseline
Goal: an optional rendering mode that emulates the terminal cell grid explicitly - each styled run emitted as inline-block with exact integer-px cell width/height, inner line-height equal to the cell, overflow hidden, vertical-align top (newlines outside spans) - making row/column geometry fully deterministic instead of dependent on font metrics, inline-background box heights and sub-pixel glyph rasterization. Optionally decompose half-block cells (U+2580/2584/258C/2590 and quadrants) into sized background rectangles for complete seam-proofing.
Acceptance: the testansi gallery samples show no vertical or horizontal seams in chrome and firefox at 100%/125%/150% zoom (the residual shade/half-block seams accepted in the span-mode recipe are eliminated); cost (page size, loss of text selectability granularity if any) measured and documented; span mode remains the default with cell-grid selectable per block.
## Context
The 2026-07-14 seam investigation (scratch/argdoc2man-trial-2026-07-13/seam_compare.tcl)
settled a span-mode recipe (integer 9px advance, tight pitch + scaleY aspect, solid-block
background sealing, integer 17px doc pitch) that reduces but cannot eliminate seams:
shade (U+2591-2593) and half-block glyph edges still antialias against differing
neighbours at fractional device-pixel positions (display scaling), and inline background
box height is a font-metric browsers don't agree on (metric normalization was tried and
rejected - see fontprep/README.md). Cell-grid emulation removes every one of those
degrees of freedom at the cost of heavier markup. Only worth building if/when the
accepted residual seams stop passing review - the glance test passes today.

18
goals/G-081-argdoc-build-pipeline.md

@ -0,0 +1,18 @@
# G-081 punk::args documentation build pipeline (dev doc.* integration)
Status: proposed
Scope: src/modules/punk/mix/commandset/doc-999999.0a1.0.tm (doc.* commandset - new generation subcommands), src/scriptapps/tools/punkargs_to_doctools.tcl + punkargs_punknative.tcl (prototypes to productize), src/scriptapps/tools/fontprep/ (shared doc-set asset in link mode), src/doc tree conventions, src/tests coverage
Goal: 'dev doc.*' generates the external documentation set from punk::args definitions as the source of truth, per package, against the DEV modules (src/modules 999999 versions - not bootsupport snapshots): (a) doctools .man output (Tcl-standard synopsis conventions) validated via dev doc.validate/dtplite and feeding the existing kettle machinery; (b) punk-native html via the G-078 api with a shared punkdoc-mono.woff2 + css (assets link mode, one cached font per doc set) and (c) GFM markdown. Includes the productization items the prototypes deferred: per-form arg tables without repeated panels (prototype has a trim heuristic - production should render per-form tables directly from the spec), deeper-namespace/ensemble command pages (e.g. punk::ansi::ansistring - the prototypes' one-level namespace filter skips them), module/package-level info at page top once G-075 surfaces @package ids, large choice sets as structured lists in markdown, and doctools emitter gaps (@examples -> [example], @seealso/@doc -> [see_also]/[uri], choicelabels).
Acceptance: a doc.* subcommand generates all three formats for a nominated package list from dev modules in one run; doctools output validates clean (dtplite) and html pages carry the shared font/css by reference; deeper-namespace registered ids appear on the owning package's page (or their own); output tree location and vcs policy decided and documented (generated-vs-committed); at least punk::path + punk::ansi + one ensemble-rich package covered by a regression pin.
## Context
Follows the 2026-07-13 assessment (punk::args -> doctools expressibility) and trials:
punkargs_to_doctools.tcl (doctools, Tcl-standard by decision - punk s-style is out of
scope for that target) and punkargs_punknative.tcl (terminal-faithful html + markdown)
both work per-package against bootsupport modules, invoked manually. The doctools
comment blocks in source remain interim until this pipeline is validated, after which
worthwhile prose migrates into punk::args blocks (original long-term plan). dtplite
validation infrastructure (bin/dtplite.cmd + dev doc.validate) is in place.
Related: G-075 (@package ids), G-078 (html api), G-079 (default colours), G-014/G-068
(argdoc/moduledoc workflows).

72
goals/G-074-punkargs-multiform-ambiguity-lint.md → goals/archive/G-074-punkargs-multiform-ambiguity-lint.md

@ -1,6 +1,6 @@
# G-074 punk::args multiform ambiguity analysis: on-demand form-overlap detection with sanctioned-overlap annotation
Status: proposed
Status: achieved 2026-07-13
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.
@ -84,3 +84,73 @@ string, none-validating) and pairs with no discriminating slot at all.
- 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.
## Progress
2026-07-13 (agent): implemented whole in punk::args 0.12.0 + tclcore moduledoc 0.3.4.
Command spelling: punk::args::formcheck <id> ?-return dict|summary? (default dict;
the dict carries the summary text as a key, so one return shape serves both the
G-055 gate and interactive use). Sanction spelling: @form -overlapallowed
<formname-list> on either pair member (stored in the FORMS <fid> dict by the
existing @form key-merge - no record-parsing change); unknown form names rejected
in resolve's end-of-forms cycle (all forms exist by then).
Design refinement over the drafted pure-static pass, adopted during implementation:
the static analysis PROPOSES, a real parse CONFIRMS. The pairwise pass enumerates
each form's positional word-slot chains (private::formcheck_chains - leaders then
values, branching on -optional args and ?-wrapped clause members, -multiple capped
at 2 repetitions), screens aligned equal-length chains per position
(formcheck_slotinfo/formcheck_slot_accepts - discriminator words via
choiceword_match exactly as drafted, type witnesses from a small table, unknown
types optimistically 'maybe'), and then parses each candidate witness arglist
against both forms individually (parse_status -form <f> - no raise, no
user-supplied words). Only a witness that parses cleanly against BOTH forms is
reported, which upgrades the no-false-alarm requirement from 'conservative
heuristic' to 'true by construction' - and the confirmed witness is exactly the
report's example arglist. The miss/report boundary (documented in the formcheck
help): witness derivation can fail for types without a derivable word, forms
requiring options (options excluded from the positional model as drafted), and
enumeration caps.
Findings on the motivating models:
- ::lseq: range/start_count type_weakness, witness {1 count 1} (range's
number|expr end slot swallows the literalprefix(count) word) - PLUS a second
real finding the static draft didn't predict: range/count type_weakness,
witness {1 by 1} (same expr-typed end slot swallows count-form's 'by'
discriminator). Both genuinely raise multipleformmatches; both left
unsanctioned (actionable - resolved when an expr syntax-validating type
arrives, G-069/G-070).
- ::after: exactly one finding, cancelid/cancelscript structural, witness
{cancel after#}; sanctioned via -overlapallowed on the cancelid @form line
(tclcore 0.3.4) leaving zero unsanctioned findings. Parse honesty pinned:
multipleformmatches still raises for id-shaped cancel words.
- No findings: ::punk::args::parse (withid/withdef literals), the afterish
fixture (int vs literal leading slots), the sharedform fixture (arity windows).
Tests: new args/formcheck.test (7 tests) - no-finding cases, class + sanction on a
local fixture, unknown-form resolve rejection, -return summary, ::lseq/::after
pins (witnesses re-verified against both forms inside the test), sanction
parse-neutrality. Full punk::args suite 210 passed / 0 failed; punk::ns suite
57/57.
Acceptance review: two known G-041 cases reported with the required classes -
yes (::lseq range/start_count type-weakness unsanctioned; ::after
cancelid/cancelscript documented-overlap sanctioned). Nothing reported for
discriminated multiform definitions - yes (parse pair + fixtures pinned).
Classifications distinguish type-weakness from structural - yes (relation
recorded per witness slot). Sanction downgrades to acknowledged without parse
effect, rejects unknown forms at resolve, adopted for after cancel - yes (all
pinned). Conservative in the documented direction - yes, strengthened: findings
are witness-confirmed so false alarms are impossible by construction; misses
documented with the slot model in the command help. No parse of user-supplied
words (witnesses are synthetic), no define/resolve cost for definitions that
never call it (only the O(list) -overlapallowed name check when the key is
present) - yes. New testsuite covering finding classes/sanction/rejection/
no-finding - yes. Full suite passes - yes.
Deferred (recorded, not blocking): G-055's workflow doc gains the formcheck gate
step when G-055 activates (noted in its detail file); punk::ns::cmdhelp does not
yet surface sanctioned-overlap acknowledgement in rendered help (the G-041
both-forms marking already shows the ambiguity - display-side integration can
ride a future doc-surface goal).

2
punkproject.toml

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

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

@ -8616,7 +8616,11 @@ namespace eval punk {
warnings for known Tcl bugs affecting this interpreter
and known bugs in bundled library packages
(as detected by the punk::lib::check::has_tclbug_*
and has_libbug_* checks)."
and has_libbug_* checks).
A warning whose buginfo reports a shipped punkshell
mitigation keeps its severity level but is annotated
'(mitigated)' and rendered subdued (grey), with the
mitigation described."
@values -min 0 -max 0
}
proc tcl {context args} {
@ -8639,17 +8643,36 @@ namespace eval punk {
if {[dict exists $buginfo level]} {
set level [dict get $buginfo level]
}
#mitigated is an axis orthogonal to level: the defect keeps its severity
#classification but a shipped punkshell mitigation covers it, so the
#warning renders subdued (grey) with a '(mitigated)' annotation and any
#mitigation text from the buginfo dict.
set mitigated 0
if {[dict exists $buginfo mitigated]} {
set mitigated [dict get $buginfo mitigated]
}
if {$mitigated} {
set highlight [punk::ansi::a+ term-grey]
} else {
switch -- $level {
minor {set highlight [punk::ansi::a+ cyan]}
medium {set highlight [punk::ansi::a+ yellow]}
major {set highlight [punk::ansi::a+ red bold]}
default {set highlight ""}
}
}
set levelshown $level
if {$mitigated} {
append levelshown " (mitigated)"
}
set indent " "
append warningblock \n $highlight "warning level: $level $bp triggered."
append warningblock \n $highlight "warning level: $levelshown $bp triggered."
if {[dict exists $buginfo description]} {
append warningblock \n "[punk::lib::indent [dict get $buginfo description] $indent]"
}
if {[dict exists $buginfo mitigation] && [dict get $buginfo mitigation] ne ""} {
append warningblock \n "[punk::lib::indent "mitigated: [dict get $buginfo mitigation]" $indent]"
}
if {[dict exists $buginfo url] && [dict get $buginfo url] ne ""} {
#full reference url (e.g. non tcl-core trackers such as tcludp)
append warningblock \n "${indent}see [punk::ansi::hyperlink [dict get $buginfo url]]"

3
src/modules/punk-buildversion.txt

@ -1,6 +1,7 @@
0.2.5
0.2.6
#First line must be a semantic version number
#all other lines are ignored.
#0.2.6 - G-076: 'help tcl' renders the mitigated/mitigation buginfo axis (punk::lib 0.4.3+): a triggered check reporting mitigated keeps its severity level but displays 'warning level: <level> (mitigated)' in subdued grey (term-grey foreground) instead of the level colour, followed by an indented 'mitigated: <text>' block when mitigation text is supplied. Unmitigated warnings render unchanged.
#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

2
src/modules/punk/AGENTS.md

@ -29,7 +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::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). The watchdog is version-conditional (G-076): arming additionally requires `punk::lib::check::has_tclbug_console_deadspin` to report the runtime affected - the same check `help tcl` surfaces - gated by `check::tclbug_console_deadspin_fixed_in` (empty until a released Tcl containing the verified upstream fix passes the G-039 kill-procedure re-verification; see `goals/G-076-tcl9-deadconsole-fix-adoption.md`).
- 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`.

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

@ -820,8 +820,17 @@ tcl::namespace::eval punk::args {
%B%@form%N% ?opt val...?
(used for commands with multiple forms)
directive-options: -form <list> -synopsis <string>
-overlapallowed <formname-list>
The -synopsis value allows overriding the auto-calculated
synopsis.
The -overlapallowed value names other forms this form is
KNOWN to overlap with (an argument list can cleanly match
both - e.g 'after cancel <id|script>' where real Tcl
disambiguates by runtime state). It sanctions the overlap
for punk::args::formcheck reporting only - parse behaviour
is unaffected (an ambiguous argument list still raises
multipleformmatches). Unknown form names are rejected when
the definition is resolved.
%B%@formdisplay%N% ?opt val...?
directive-options: -header <str> (text for header row of table)
-body <str> (override autogenerated arg info for form)
@ -3606,6 +3615,19 @@ tcl::namespace::eval punk::args {
dict for {fid FDICT} $F {
dict set F $fid {} ;#detach
#G-074: @form -overlapallowed sanctions a known form overlap for
#punk::args::formcheck reporting - it must name forms that exist in this
#definition. Validated here because all forms have been created by now.
#(the current form's key still exists in F while detached - self-reference
#is pointless but harmless and not rejected)
if {[dict exists $FDICT -overlapallowed]} {
foreach oaform [dict get $FDICT -overlapallowed] {
if {![dict exists $F $oaform]} {
error "punk::args::resolve - @form -overlapallowed for form '$fid' names unknown form '$oaform' (known forms: [dict keys $F]) @id:$DEF_definition_id"
}
}
}
#set mashargs [dict get $F $fid OPT_MASHES]
set mashargs [dict get $FDICT OPT_MASHES]
if {[llength $mashargs]} {
@ -6979,6 +7001,460 @@ tcl::namespace::eval punk::args {
return $ranked
}
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
#G-074 punk::args::formcheck - on-demand multiform ambiguity analysis.
#Static pairwise pass over a definition's resolved FORMS: enumerate the positional
#word-slot chains each form can present, derive candidate witness arglists for
#aligned chains of equal length, and CONFIRM each candidate by parsing the witness
#against both forms (parse_status - no error raise, no user-supplied words).
#Only a witnessed overlap is reported, so fully discriminated form pairs cannot
#false-alarm; the documented miss direction is witness derivation - exotic types
#without a derivable witness word, required options (options are excluded from the
#positional model), and enumeration caps (-multiple repetitions, chain counts).
#Nothing here runs at define/resolve time apart from the cheap -overlapallowed
#known-form validation.
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
#enumerate the positional word-slot chains a form can present - leaders then values,
#branching on -optional arguments and ?-wrapped optional clause members, with
#-multiple arguments capped at multiple_cap repetitions (same member pattern per
#repetition). Each chain is a list of slots {arg <argname> type <membertype>} - one
#slot per word position. @leaders/@values min/max windows are not re-checked here:
#every candidate overlap is confirmed by a real parse before being reported.
proc private::formcheck_chains {formdict {multiple_cap 2} {chain_cap 400}} {
set ARG_INFO [dict get $formdict ARG_INFO]
set chains [list {}]
foreach argname [list {*}[dict get $formdict LEADER_NAMES] {*}[dict get $formdict VAL_NAMES]] {
set typelist [dict get $ARG_INFO $argname -type]
#member patterns for a single occurrence of this argument
set patterns [list {}]
foreach tp $typelist {
if {[string match {\?*\?} $tp]} {
set inclusions [list "" [string trim $tp ?]]
} else {
set inclusions [list $tp]
}
set withmember [list]
foreach p $patterns {
foreach inc $inclusions {
if {$inc eq ""} {
lappend withmember $p
} else {
lappend withmember [list {*}$p $inc]
}
}
}
set patterns $withmember
}
#an occurrence consumes at least one word
set occurrence_patterns [lsearch -all -inline -not -exact $patterns {}]
if {![llength $occurrence_patterns]} {
continue
}
if {[dict get $ARG_INFO $argname -multiple]} {
set max_occurrences $multiple_cap
} else {
set max_occurrences 1
}
if {[dict get $ARG_INFO $argname -optional]} {
set min_occurrences 0
} else {
set min_occurrences 1
}
set newchains [list]
foreach chain $chains {
for {set occ $min_occurrences} {$occ <= $max_occurrences} {incr occ} {
if {$occ == 0} {
lappend newchains $chain
continue
}
foreach pat $occurrence_patterns {
set extended $chain
for {set r 0} {$r < $occ} {incr r} {
foreach membertype $pat {
lappend extended [dict create arg $argname type $membertype]
}
}
lappend newchains $extended
}
}
}
set chains $newchains
if {[llength $chains] > $chain_cap} {
#conservative truncation (documented miss direction)
set chains [lrange $chains 0 $chain_cap-1]
}
}
return $chains
}
#static description of one positional slot for overlap screening:
# disc 1 if the slot is a discriminator (literal/literalprefix alternates
# or a restricted choice set - the same notion as form_literal_affinity)
# discwords the discriminator's words (choice words, literal values)
# permissive 1 if any type alternate performs no word validation (any/none/string/
# ansistring/globstring/expr/script) - the 'type-weakness' side
# witnesses candidate words derivable from the types (a word satisfying the type)
proc private::formcheck_slotinfo {formdict slot} {
set ARG_INFO [dict get $formdict ARG_INFO]
set argname [dict get $slot arg]
set tp [string trim [dict get $slot type] ?]
set disc 0
set discwords [list]
set permissive 0
set witnesses [list]
if {[dict exists $ARG_INFO $argname -choices] || [dict exists $ARG_INFO $argname -choicegroups]} {
set choicewords [list]
if {[dict exists $ARG_INFO $argname -choices]} {
lappend choicewords {*}[dict get $ARG_INFO $argname -choices]
}
if {[dict exists $ARG_INFO $argname -choicegroups]} {
dict for {_grp grpmembers} [dict get $ARG_INFO $argname -choicegroups] {
lappend choicewords {*}$grpmembers
}
}
if {[Dict_getdef $ARG_INFO $argname -choicerestricted 1]} {
set disc 1
lappend discwords {*}$choicewords
} else {
#unrestricted choices don't discriminate but their words satisfy validation
lappend witnesses {*}$choicewords
}
}
foreach alt [split_type_expression $tp] {
if {[llength $alt] == 2} {
lassign $alt t param
switch -exact -- $t {
literal - literalprefix {
set disc 1
lappend discwords $param
}
stringstartswith - stringendswith - stringcontains {
lappend witnesses $param
}
default {}
}
} else {
switch -exact -- $alt {
any - none - string - ansistring - globstring - expr - script - "" {
set permissive 1
}
int - integer - number - bool - boolean {
lappend witnesses 1
}
double {
lappend witnesses 1.5
}
list {
lappend witnesses x
}
dict {
lappend witnesses {k v}
}
default {}
}
}
}
if {$permissive} {
lappend witnesses x
}
return [dict create disc $disc discwords $discwords permissive $permissive witnesses $witnesses]
}
#static acceptance screen of a word against a slot - returns yes|no|maybe.
#Restricted choice sets are screened via choiceword_match (the shared G-040
#resolver) so screening cannot diverge from parse acceptance; type alternates the
#screen doesn't model return maybe - the confirming parse decides. This screen only
#prunes candidate witnesses: a wrongly optimistic maybe costs a parse attempt, it
#cannot produce a false finding.
proc private::formcheck_slot_accepts {formdict slot word} {
set ARG_INFO [dict get $formdict ARG_INFO]
set argname [dict get $slot arg]
set tp [string trim [dict get $slot type] ?]
if {([dict exists $ARG_INFO $argname -choices] || [dict exists $ARG_INFO $argname -choicegroups])
&& [Dict_getdef $ARG_INFO $argname -choicerestricted 1]} {
#a restricted choice set rejects non-choice words regardless of type
set cw_allchoices [list]
if {[dict exists $ARG_INFO $argname -choices]} {
lappend cw_allchoices {*}[dict get $ARG_INFO $argname -choices]
}
if {[dict exists $ARG_INFO $argname -choicegroups]} {
dict for {_grp grpmembers} [dict get $ARG_INFO $argname -choicegroups] {
lappend cw_allchoices {*}$grpmembers
}
}
set cwm [choiceword_match $word\
[Dict_getdef $ARG_INFO $argname -nocase 0]\
$cw_allchoices\
[Dict_getdef $ARG_INFO $argname -choicealiases {}]\
[Dict_getdef $ARG_INFO $argname -choiceprefix 1]\
[Dict_getdef $ARG_INFO $argname -choiceprefixdenylist {}]\
[Dict_getdef $ARG_INFO $argname -choiceprefixreservelist {}]\
]
return [expr {[dict get $cwm matched] ? "yes" : "no"}]
}
set has_maybe 0
foreach alt [split_type_expression $tp] {
if {[llength $alt] == 2} {
lassign $alt t param
switch -exact -- $t {
literal {
if {$word eq $param} {return yes}
}
literalprefix {
if {$word ne "" && [string equal -length [string length $word] $word $param]} {return yes}
}
stringstartswith {
if {[string range $word 0 [string length $param]-1] eq $param} {return yes}
}
stringendswith {
set param_last [expr {[string length $param]-1}]
if {$param eq "" || [string range $word end-$param_last end] eq $param} {return yes}
}
stringcontains {
if {[string first $param $word] >= 0} {return yes}
}
default {
set has_maybe 1
}
}
} else {
switch -exact -- $alt {
any - none - string - ansistring - globstring - expr - script - "" {
return yes
}
int - integer {
if {[string is integer -strict $word]} {return yes}
}
number - double {
if {[string is double -strict $word]} {return yes}
}
bool - boolean {
if {[string is boolean -strict $word]} {return yes}
}
list {
if {![catch {llength $word}]} {return yes}
}
dict {
if {![catch {dict size $word}]} {return yes}
}
default {
set has_maybe 1
}
}
}
}
return [expr {$has_maybe ? "maybe" : "no"}]
}
#overlap check of one form pair. For each pair of equal-length slot chains, derive
#per-position candidate witness words (discriminator words and type witnesses from
#both slots, screened by formcheck_slot_accepts) and confirm candidates with a real
#single-form parse of the witness against BOTH forms. Returns a finding dict
#(forms/class/length/witness/slots) for the first confirmed witness, or {} if no
#candidate was confirmed within the parse budget.
#class: type_weakness if some witness position aligns a discriminator with a
#permissive (non-validating) type on the other side; structural otherwise (the
#forms genuinely share an argument shape - 'after cancel id|script' class).
proc private::formcheck_pair {id spec fa fb {parse_budget 32}} {
set fda [dict get $spec FORMS $fa]
set fdb [dict get $spec FORMS $fb]
set chains_b_bylen [dict create]
foreach cb [formcheck_chains $fdb] {
dict lappend chains_b_bylen [llength $cb] $cb
}
set parses 0
foreach ca [formcheck_chains $fda] {
set L [llength $ca]
if {$L == 0 || ![dict exists $chains_b_bylen $L]} {continue}
foreach cb [dict get $chains_b_bylen $L] {
#per-position candidate words + relation classification
set positions [list]
set viable 1
for {set i 0} {$i < $L} {incr i} {
set slota [lindex $ca $i]
set slotb [lindex $cb $i]
set ia [formcheck_slotinfo $fda $slota]
set ib [formcheck_slotinfo $fdb $slotb]
set candidates [list] ;#{word score} - score 2: both sides screened yes
foreach w [list {*}[dict get $ia discwords] {*}[dict get $ib discwords]\
{*}[dict get $ia witnesses] {*}[dict get $ib witnesses]] {
if {[lsearch -exact -index 0 $candidates $w] >= 0} {continue}
set acc_a [formcheck_slot_accepts $fda $slota $w]
set acc_b [formcheck_slot_accepts $fdb $slotb $w]
if {$acc_a eq "no" || $acc_b eq "no"} {continue}
lappend candidates [list $w [expr {($acc_a eq "yes") + ($acc_b eq "yes")}]]
}
if {![llength $candidates]} {
set viable 0
break
}
set candidates [lrange [lsort -integer -decreasing -index 1 $candidates] 0 2]
if {[dict get $ia disc] && ![dict get $ib disc] && [dict get $ib permissive]} {
set relation discriminator_vs_permissive
} elseif {[dict get $ib disc] && ![dict get $ia disc] && [dict get $ia permissive]} {
set relation discriminator_vs_permissive
} elseif {[dict get $ia disc] && [dict get $ib disc]} {
set relation shared_discriminator
} else {
set relation co_satisfiable_types
}
lappend positions [list [lmap c $candidates {lindex $c 0}] $relation\
[dict get $slota arg] [dict get $slotb arg]]
}
if {!$viable} {continue}
#witness combinations: best candidate per position, then vary one
#position at a time through its alternates
set base [lmap p $positions {lindex $p 0 0}]
set trylist [list $base]
for {set i 0} {$i < $L} {incr i} {
foreach altword [lrange [lindex $positions $i 0] 1 end] {
set varied $base
lset varied $i $altword
lappend trylist $varied
}
}
foreach witness $trylist {
if {$parses >= $parse_budget} {break}
incr parses 2
if {![dict get [parse_status $witness -form [list $fa] withid $id] ok]} {continue}
if {![dict get [parse_status $witness -form [list $fb] withid $id] ok]} {continue}
#confirmed overlap - classify and report
set class structural
set slotreport [list]
for {set i 0} {$i < $L} {incr i} {
lassign [lindex $positions $i] _cands relation arga argb
if {$relation eq "discriminator_vs_permissive"} {
set class type_weakness
}
lappend slotreport [dict create word [lindex $witness $i]\
args [list $arga $argb] relation $relation]
}
return [dict create forms [list $fa $fb] class $class length $L\
witness $witness slots $slotreport]
}
if {$parses >= $parse_budget} {break}
}
if {$parses >= $parse_budget} {break}
}
return {}
}
lappend PUNKARGS [list {
@id -id ::punk::args::formcheck
@cmd -name punk::args::formcheck\
-summary\
"Analyse a multiform definition for form pairs an argument list could match simultaneously."\
-help\
"Analyse the definition identified by id for AMBIGUOUS form pairs: pairs
of @form forms for which some argument list cleanly matches both, so a
parse without -form would raise a multipleformmatches error.
The analysis is static-first: it aligns the positional argument slots of
each form pair (leaders then values - options are order-free and excluded
from the positional model), derives candidate 'witness' argument lists,
and only reports a pair after CONFIRMING a witness by parsing it against
both forms individually. A reported overlap is therefore always real -
fully discriminated form pairs cannot be false-alarmed - while the miss
direction is conservative: overlaps whose witness the analysis cannot
derive (exotic validation types, forms requiring options, deep -multiple
repetition) may go unreported. No user-supplied argument words are
parsed, and definitions that never call formcheck pay no cost at
define/resolve time.
Finding classes:
type_weakness a discriminator slot (literal()/literalprefix()
alternates or a restricted choice set) in one form
aligns with a permissive type that performs no word
validation (any/none/string/ansistring/globstring/
expr/script) in the other - the permissive type
swallows the discriminator word (e.g 'lseq 1 count 5':
range's end slot typed number|expr accepts the word
'count'). Usually fixable by tightening the type.
structural the forms genuinely share an argument shape with no
discriminating slot difference (e.g 'after cancel x'
where x is id-shaped - real Tcl disambiguates by
runtime state). If intended, sanction the pair with
@form -overlapallowed so it reports as acknowledged.
A pair sanctioned via @form -overlapallowed <formname-list> (on either
member) is still reported, with sanctioned 1 - the sanction documents
intent for this analysis only and never changes parse behaviour.
Returns a dict:
id the analysed definition id
form_names declared forms in declaration order
pairs every form pair analysed (list of 2-element lists)
findings dict keyed by pair {formA formB} - each finding has
forms, class, length, witness (a confirmed argument
list matching both forms), slots (per-position word,
argument names and slot relation) and sanctioned (0|1)
unsanctioned pair keys of findings not covered by -overlapallowed
(the actionable subset - e.g a verification gate can
require this to be empty)
summary human-readable report of the above
With -return summary only the summary text is returned."
-return -type string -default dict -choices {dict summary} -help\
"dict: full machine-parsable analysis (includes the summary as a key).
summary: the human-readable report text only."
@values -min 1 -max 1
id -type string -help\
"id of a punk::args definition (as accepted by punk::args::get_spec)"
}]
proc formcheck {args} {
set argd [punk::args::parse $args withid ::punk::args::formcheck]
lassign [dict values $argd] leaders opts values received
set id [dict get $values id]
set opt_return [dict get $opts -return]
set spec [get_spec $id]
if {$spec eq ""} {
error "punk::args::formcheck - no such id: '$id'"
}
set form_names [dict get $spec form_names]
set pairs [list]
set findings [dict create]
set unsanctioned [list]
for {set i 0} {$i < [llength $form_names]} {incr i} {
for {set j [expr {$i+1}]} {$j < [llength $form_names]} {incr j} {
set fa [lindex $form_names $i]
set fb [lindex $form_names $j]
lappend pairs [list $fa $fb]
set finding [private::formcheck_pair $id $spec $fa $fb]
if {$finding eq ""} {continue}
set sanctioned 0
if {$fb in [Dict_getdef $spec FORMS $fa -overlapallowed {}]
|| $fa in [Dict_getdef $spec FORMS $fb -overlapallowed {}]} {
set sanctioned 1
}
dict set finding sanctioned $sanctioned
dict set findings [list $fa $fb] $finding
if {!$sanctioned} {
lappend unsanctioned [list $fa $fb]
}
}
}
set summarylines [list]
lappend summarylines "punk::args::formcheck $id: [llength $form_names] form(s), [llength $pairs] pair(s) analysed, [dict size $findings] overlap(s) ([llength $unsanctioned] unsanctioned)"
dict for {pairkey finding} $findings {
lassign $pairkey fa fb
set tag [expr {[dict get $finding sanctioned] ? "sanctioned" : "UNSANCTIONED"}]
lappend summarylines " $tag [dict get $finding class] overlap: $fa vs $fb - witness arglist {[dict get $finding witness]} parses cleanly against both forms"
foreach slot [dict get $finding slots] {
lassign [dict get $slot args] arga argb
lappend summarylines " word '[dict get $slot word]' satisfies $fa/$arga and $fb/$argb ([dict get $slot relation])"
}
}
if {![dict size $findings]} {
lappend summarylines " no overlapping form pairs detected (static analysis - see the punk::args::formcheck help for the miss/report boundary)"
}
set summary [join $summarylines \n]
if {$opt_return eq "summary"} {
return $summary
}
return [dict create id $id form_names $form_names pairs $pairs findings $findings unsanctioned $unsanctioned summary $summary]
}
lappend PUNKARGS [list {
@id -id ::punk::args::parse_status
@cmd -name punk::args::parse_status\

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

@ -1,6 +1,7 @@
0.11.2
0.12.0
#First line must be a semantic version number
#all other lines are ignored.
#0.12.0 - G-074: new punk::args::formcheck - on-demand multiform ambiguity analysis. Static pairwise pass over a definition's resolved FORMS: enumerates the positional word-slot chains each form can present (leaders then values, branching on -optional arguments and ?-wrapped optional clause members, -multiple capped; options excluded - order-free), derives candidate witness arglists for aligned equal-length chains (discriminator words + type-derived witnesses, screened via choiceword_match/type tests so screening can't diverge from parse acceptance), and only reports a form pair after CONFIRMING a witness with a real single-form parse against BOTH forms (parse_status; no user-supplied words, nothing added to define/resolve cost) - so fully discriminated pairs cannot false-alarm and every reported witness is a genuine multipleformmatches arglist; the documented miss direction is witness derivation (exotic types, forms requiring options, enumeration caps). Findings classify as type_weakness (a discriminator slot - literal()/literalprefix()/restricted choices - aligned with a permissive non-validating type: any/none/string/ansistring/globstring/expr/script; the 'lseq 1 count 5' class) vs structural (forms genuinely share an argument shape; the 'after cancel id|script' class). New @form key -overlapallowed <formname-list> sanctions a KNOWN overlap on either pair member: the finding reports with sanctioned 1 and leaves the unsanctioned list (the actionable/gate subset) - parse behaviour is never affected (multipleformmatches still raises); unknown form names rejected at definition resolve. Returns machine-parsable dict (id/form_names/pairs/findings with witness+per-slot relations/unsanctioned/summary) or -return summary for the report text. New testsuite formcheck.test; @form directive doc updated. Proving consumers: tclcore ::lseq (reports range/start_count AND range/count - both real, root cause the expr-typed end slot) and ::after (exactly the sanctioned cancelid/cancelscript documented overlap).
#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.

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

@ -4591,7 +4591,11 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
#@form -form {cancelid} -synopsis "after cancel id"
@form -form {cancelid}
#The cancelid/cancelscript overlap is DOCUMENTED behaviour (an id-shaped word
#is genuinely ambiguous - see the id -type comment below), so it is sanctioned
#for punk::args::formcheck reporting (G-074). Parse behaviour is unaffected:
#'after cancel <id-shaped-word>' still raises multipleformmatches.
@form -form {cancelid} -overlapallowed {cancelscript}
@leaders -min 1 -max 1
cancel -choices {cancel}
@values -min 1 -max 1

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

@ -1,6 +1,7 @@
0.3.3
0.3.4
#First line must be a semantic version number
#all other lines are ignored.
#0.3.4 - G-074: the documented cancelid/cancelscript overlap on ::after cancel is sanctioned via the new @form -overlapallowed key (punk::args 0.12.0) - punk::args::formcheck now reports it as an acknowledged (sanctioned) structural overlap rather than an actionable finding, leaving ::after with zero unsanctioned findings. Parse behaviour unchanged: an id-shaped 'after cancel' word still raises multipleformmatches (the runtime-liveness ambiguity real Tcl resolves by trying the id first - 0.3.0 record). ::lseq deliberately NOT sanctioned: its range/start_count and range/count overlaps are type-weakness findings (the expr-typed end slot swallows the 'count'/'by' discriminator words) - kept visible pending an expr syntax-validating type (G-069/G-070 territory, G-055 operand-typing record).
#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).

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

@ -250,6 +250,63 @@ tcl::namespace::eval punk::lib::check {
return [dict create bug $bug bugref e38dc74e2 description $description level medium]
}
#G-076: version gate for the tcl9 dead-console defect (upstream ticket f10d91c2d3, root-caused
#in G-039). Shared by the 'help tcl' warning and the repl dead-console watchdog arming (both
#consult has_tclbug_console_deadspin). Empty = no released Tcl is known to contain the upstream
#fix, so every Tcl 9 windows runtime classifies as affected. Set this only after the G-039 kill
#procedure, re-run on the fixed released runtime with the watchdog disabled, shows a clean
#script-visible eof exit (see goals/G-076-tcl9-deadconsole-fix-adoption.md).
variable tclbug_console_deadspin_fixed_in ""
#pure classifier, separated for testability - facts in, verdict out
proc tclbug_console_deadspin_applies {platform tclversion fixed_in} {
if {$platform ne "windows"} {
return 0
}
if {![package vsatisfies $tclversion 9]} {
#tcl 8.6 has a different console driver - out of scope per G-039 (user decision 2026-07-12)
return 0
}
if {$fixed_in eq ""} {
#no released fix known - all Tcl 9 windows runtimes affected
return 1
}
return [expr {[package vcompare $tclversion $fixed_in] < 0}]
}
proc has_tclbug_console_deadspin {} {
#Tcl 9 windows console driver defect pair (win/tclWinConsole.c): when the hosting
#console dies (killed conhost/terminal), (a) ConsoleEventProc drops the error/EOF
#notification so a stdin readable fileevent never fires - the script is blind to the
#dead console; (b) ConsoleReaderThread busy-loops on the persistent error, spinning
#~2 cores until the channel is closed. Root-caused 2026-07-12 - see archived goal
#G-039; upstream ticket f10d91c2d3 filed 2026-07-12. punk::repl >= 0.5.0 mitigates
#with a console liveness watchdog (repl::console_watchdog) gated on this same check.
#Version-based detection only - a behavioural probe would require killing a console.
#The buginfo dict carries the mitigated/mitigation axis: level stays major (the core
#defect's severity), mitigated reports whether punk::repl >= 0.5.0 (console liveness
#watchdog) is available to this runtime - 'help tcl' renders mitigated warnings subdued.
variable tclbug_console_deadspin_fixed_in
set bug [tclbug_console_deadspin_applies $::tcl_platform(platform) [info patchlevel] $tclbug_console_deadspin_fixed_in]
set description "Tcl 9 windows console driver: a dead console (killed conhost/terminal) is never\ndelivered to the script as a fileevent, and the core's console reader thread busy-loops\non the persistent error - an orphaned tclsh spins CPU indefinitely. Plain tclsh scripts\nreading a console stdin have no script-level escape (see goal G-076)."
set replversion [package provide punk::repl]
if {$replversion eq ""} {
#not loaded - determine what version would be provided, without loading it:
#an unsatisfiable require triggers the package unknown scan (registering ifneeded
#scripts) then fails before any load (999999.0a1.0 dev modules are alpha - below 999999).
catch {package require punk::repl 999999}
set available [package versions punk::repl]
if {[llength $available]} {
set replversion [lindex [lsort -command {package vcompare} $available] end]
}
}
set mitigated [expr {$bug && $replversion ne "" && [package vsatisfies $replversion 0.5-]}]
set mitigation ""
if {$mitigated} {
set mitigation "punk::repl $replversion is available to this runtime: its console liveness watchdog\n(armed when the repl serves the process-default console) closes the dead channel and\nexits cleanly instead of spinning. Non-repl console reads remain exposed."
}
return [dict create bug $bug bugref f10d91c2d3 description $description level major mitigated $mitigated mitigation $mitigation]
}
#has_libbug_* procs report bugs in bundled/vendored library packages rather than the Tcl core.
#They are surfaced through the same 'help tcl' warning report as the has_tclbug_* checks.

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

@ -1,6 +1,8 @@
0.4.1
0.4.3
#First line must be a semantic version number
#all other lines are ignored.
#0.4.3 - G-076: buginfo dicts gain an optional mitigated/mitigation axis (mitigated boolean + mitigation text) orthogonal to level - a check whose defect is covered by a shipped punkshell mitigation reports it so 'help tcl' can render the warning subdued while keeping the severity classification. has_tclbug_console_deadspin populates it: mitigated when punk::repl >= 0.5.0 (console liveness watchdog) is available to the runtime (udp-check-style version discovery without loading), with the watchdog scope described in the mitigation text (non-repl console reads remain exposed).
#0.4.2 - G-076: new punk::lib::check::has_tclbug_console_deadspin + tclbug_console_deadspin_applies classifier - version-based detection of the tcl9 windows dead-console defect (f10d91c2d3, root-caused in G-039: dead console never delivered as a fileevent + core reader thread busy-loop). Gate variable check::tclbug_console_deadspin_fixed_in (empty = no fixed release known - all Tcl 9 windows affected); shared by the 'help tcl' warning and repl::start's watchdog arming.
#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

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

@ -634,12 +634,16 @@ proc repl::start {args} {
#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.
#console; piped/foreign/8.6 inputs are unaffected. The version gate (G-076) is
#punk::lib::check::has_tclbug_console_deadspin - shared with the 'help tcl' warning -
#so runtimes at or past a verified fixed Tcl release (check::tclbug_console_deadspin_fixed_in)
#don't arm the probe.
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)]} {
&& ![info exists console_watchdog_afterids($inchan)]
&& [dict get [punk::lib::check::has_tclbug_console_deadspin] bug]} {
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]]
@ -983,7 +987,10 @@ namespace eval repl::argdoc {
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
windows, and only while punk::lib::check::has_tclbug_console_deadspin
reports the runtime affected (G-076 shared version gate - a Tcl release
containing the verified upstream fix for ticket f10d91c2d3 arms nothing).
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

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

@ -1,6 +1,7 @@
0.5.0
0.5.1
#First line must be a semantic version number
#all other lines are ignored.
#0.5.1 - G-076: repl::start's dead-console watchdog arming now also requires punk::lib::check::has_tclbug_console_deadspin to report the runtime affected (shared version gate with the 'help tcl' warning; requires punk::lib 0.4.2+). Behaviour today is unchanged (gate variable check::tclbug_console_deadspin_fixed_in is empty = every Tcl 9 windows runtime affected); once a released Tcl containing the verified upstream fix for f10d91c2d3 is recorded there, such runtimes stop arming the watchdog.
#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)

2
src/scriptapps/AGENTS.md

@ -13,7 +13,7 @@ Standalone Tcl scripts that serve as entry points for Punk applications and util
- Scripts here are invoked directly by the user or by build tools, not loaded as packages.
- Wrapper configs (`.toml` files) define how scripts are packaged as platform executables.
- `<name>_wrap.toml` scriptsets are the SOURCE of the generated polyglot `.cmd` scripts in `<projectroot>/bin`, and bin-deployed scriptsets live in the `bin/` subfolder here (e.g `bin/runtime.ps1` + `bin/runtime.bash` + `bin/runtime_wrap.toml` -> `<projectroot>/bin/runtime.cmd` via `punk::mix::commandset::scriptwrap::multishell runtime -askme 0`, run from `src/scriptapps/bin`, alongside the `getzig.*` scriptset). Fixes to a `bin/*.cmd` polyglot are made in the scriptset sources and re-wrapped - never in the output file. Regenerate and commit the bin output together with payload changes: `src/tests/modules/punk/mix/testsuites/scriptwrap/multishell.test` pins the runtime scriptset round-trip byte-identical against `bin/runtime.cmd`.
- `<name>_wrap.toml` scriptsets are the SOURCE of the generated polyglot `.cmd` scripts in `<projectroot>/bin`, and bin-deployed scriptsets live in the `bin/` subfolder here (e.g `bin/runtime.ps1` + `bin/runtime.bash` + `bin/runtime_wrap.toml` -> `<projectroot>/bin/runtime.cmd` via `punk::mix::commandset::scriptwrap::multishell runtime -askme 0`, run from `src/scriptapps/bin`, alongside the `getzig.*` scriptset). Fixes to a `bin/*.cmd` polyglot are made in the scriptset sources and re-wrapped - never in the output file. Regenerate and commit the bin output together with payload changes: `src/tests/modules/punk/mix/testsuites/scriptwrap/multishell.test` pins the runtime scriptset round-trip byte-identical against `bin/runtime.cmd`, and `src/tests/shell/testsuites/binscripts/dtplite.test` pins the dtplite scriptset (`dtplite.tcl` + `dtplite_wrap.toml`, wrapped from this folder) against `bin/dtplite.cmd` the same way. Payload scripts embedded in polyglots must be LF-only - the wrap embeds payload bytes verbatim and the multishell output contract is LF-only.
- The `spud/` directory holds the spud build tool's app scripts.
- The `tools/` directory holds miscellaneous build and deployment utilities.

17
src/scriptapps/dtplite.tcl

@ -16,7 +16,22 @@
# Meta license BSD
# @@ Meta End
package require dtplite 1.0.5
if {[catch {package require dtplite 1.0.5}]} {
# Fall back to the project-vendored tcllib (pure Tcl) when the invoking
# tclsh has no tcllib installed. Works both unwrapped (src/scriptapps/dtplite.tcl)
# and wrapped (<projectroot>/bin/dtplite.cmd) - each is one level below a folder
# containing (or sibling to) src/vendorlib_tcl9/<arch>/tcllib<ver>
set selfdir [file dirname [file dirname [file normalize [info script]/__]]]
foreach libdir [glob -nocomplain -types d -directory $selfdir\
../vendorlib_tcl9/*/tcllib*\
../src/vendorlib_tcl9/*/tcllib*] {
set libdir [file normalize $libdir]
if {$libdir ni $::auto_path} {
lappend ::auto_path $libdir
}
}
package require dtplite 1.0.5
}
# dtp lite - Lightweight DocTools Processor
# ======== = ==============================

15
src/scriptapps/dtplite_wrap.toml

@ -0,0 +1,15 @@
[application]
template="punk.multishell.cmd"
as_admin=false
scripts=[
"dtplite.tcl",
]
default_outputfile="dtplite.cmd"
default_nextshellpath="/usr/bin/env tclsh"
default_nextshelltype="tcl"
win32.nextshellpath="tclsh"
win32.nextshelltype="tcl"
win32.outputfile="dtplite.cmd"

94
src/scriptapps/tools/fontprep/LICENSE-cascadia-OFL.txt

@ -0,0 +1,94 @@
Copyright (c) 2019 - Present, Microsoft Corporation,
with Reserved Font Name Cascadia Code.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

87
src/scriptapps/tools/fontprep/README.md

@ -0,0 +1,87 @@
# fontprep - punkdoc-mono web font for the punk-native documentation pipeline
`punkdoc-mono.woff2` is the pinned monospace web font consumed by the
punk-native html doc generation (`../punkargs_punknative.tcl` and its
successors). It exists because the ansi->html rendering of punk::args usage
tables, textblock output and cp437/ansi art (src/testansi) needs glyphs -
Unicode 16 octants (U+1CC00-1CEBF) and Symbols for Legacy Computing
(U+1FB00-1FBFF) in particular - that are absent from the monospace fonts
commonly installed on reader machines. Coverage was measured empirically:
see the glyph inventory probe notes below.
## What it is
A glyph subset of **Cascadia Mono v2404.23** (variable font, `wght` axis
200-700 retained so browsers render true bold from this single file),
renamed to **punkdoc-mono**. Line metrics are STOCK (hhea/typo ascent 1900 /
descent 480; win ascent 2226 / descent 480).
Cell geometry for css authors: advance = 0.5859375 em exactly (font-size
15.36px -> integer 9px cell width - integer cell widths avoid vertical seams
at styled-span boundaries). Block-element glyphs paint -480..2226 units =
1.3212890625 em (they fill the WINDOWS cell, which is the conhost/windows
terminal cell height). NOTE - rejected experiment (2026-07-14, see the
commented-out normalize_line_metrics in make_punkdoc_mono.py): setting
hhea/typo equal to the win metrics makes browsers' inline content area
1.3213em, and at row pitches below that every span's BACKGROUND bleeds ~2.5px
into neighbouring rows (wrong-coloured bars under solid blocks). Empirically
the best block-art rendering is stock metrics with a tight row pitch
(line-height:1.0) - optionally aspect-corrected with a transform:scaleY of
1.3212890625 applied to the whole rendering.
- Renaming is REQUIRED, not cosmetic: Cascadia's SIL OFL 1.1 licence declares
the Reserved Font Name "Cascadia Code", and a modified version (subsetting
is modification) must not use it. `punkdoc-mono` avoids the Cascadia name
entirely. The font's embedded name table carries the original Microsoft
copyright (nameID 0, preserved verbatim as the OFL requires), the OFL
licence statement/url (nameID 13/14), and a provenance description
(nameID 10). The original trademark record was removed (a renamed copy must
not assert Microsoft's trademark).
- Licence: SIL OFL 1.1 - full text in `LICENSE-cascadia-OFL.txt` (fetched from
the v2404.23 source tag). The OFL applies to the font only; it places no
conditions on punkshell (BSD) or on documents rendered with the font.
Track in the project licence inventory (G-063).
- No italic face is included: browsers synthesize oblique for italic spans
(advance widths are preserved, so terminal-grid alignment holds).
## Provenance (reproducibility)
- Source: https://github.com/microsoft/cascadia-code/releases/download/v2404.23/CascadiaCode-2404.23.zip
- release zip sha256: A911410626C0E09D03FA3FDDA827188FDA96607DF50FECC3C5FEE5906E33251B
- input file within zip: ttf/CascadiaMono.ttf (variable)
- input ttf sha256: 056F33BA2D9DA954750320EC6ECFB7D83CFB5F93AD593722170CD4498F5D4E95
- Tooling: python fonttools 4.63.0 + brotli (one-time prep only - the doc
BUILD never invokes python; it just reads/copies/base64s the .woff2)
- Command:
`python make_punkdoc_mono.py CascadiaMono.ttf punkdoc-unicodes.txt <outdir>`
- Output punkdoc-mono.woff2 sha256: EE068E60E0BE6D96FC97D79123DE3B1A9A35205136A4A5C97648BCA2D9A24DB6
(1561 cmap codepoints, 4319 glyphs, 79744 bytes)
Builds are byte-reproducible: the prep script loads with
recalcTimestamp=False so head.modified stays that of the source font
(verified: two consecutive builds hash identically).
## Subset ranges
`punkdoc-unicodes.txt` - whole blocks (with headroom) covering the demand
measured 2026-07-13 by the glyph inventory probe (scratch trial folder
argdoc2man-trial-2026-07-13: glyph_inventory.tcl over src/testansi decoded as
ansicat does + textblock::periodic + all textblock frametypes +
punk::args::usage = 160 unique non-ascii codepoints), plus the full CP437
repertoire and braille (U+2800-28FF) for future plot rendering.
Verified: the subset covers 157/160 of the measured inventory - identical to
the full source font. The 3 misses are deliberate non-goals: em-space U+2003
(converters should normalize exotic spaces to plain spaces - terminals treat
them as one cell) and fullwidth forms U+FF30/FF31 (inherently double-width;
CJK-font fallback territory).
## Regenerating (when the glyph domain grows)
1. Re-run the glyph inventory probe over the current content; if new
codepoints fall outside `punkdoc-unicodes.txt`, widen the ranges.
2. `pip install fonttools brotli` (any machine; a venv is fine)
3. Fetch the pinned Cascadia release (or a newer one - update shas here)
4. Run the command above; verify coverage (e.g. via
Windows.Media.GlyphTypeface against the inventory - see font_coverage.ps1
in the trial scratch folder) and that name records contain no "Cascadia"
outside the nameID 10 provenance credit.
5. Commit the new .woff2 together with updated shas in this README.

145
src/scriptapps/tools/fontprep/make_punkdoc_mono.py

@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""make_punkdoc_mono.py - one-time font-prep for the punkshell doc pipeline.
Subsets the Cascadia Mono VARIABLE font (weight axis kept, so browsers get
true bold from one file) to the punkdoc unicode ranges, renames the family to
'punkdoc-mono' (REQUIRED: Cascadia's OFL declares Reserved Font Name
'Cascadia Code' - a modified version must not use the reserved name), and
writes woff2 (for the web) + ttf (for cmap verification tooling).
usage: python make_punkdoc_mono.py <CascadiaMono.ttf> <unicodes.txt> <outdir>
"""
import sys
import os
from fontTools import version as fonttools_version
from fontTools.ttLib import TTFont
from fontTools.subset import Subsetter, Options
SRC_FAMILY_NAMES = ["Cascadia Mono", "CascadiaMono", "Cascadia Code", "CascadiaCode"]
NEW_FAMILY = "punkdoc-mono"
def parse_unicodes(path):
cps = set()
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "-" in line:
lo, hi = line.split("-")
cps.update(range(int(lo, 16), int(hi, 16) + 1))
else:
cps.add(int(line, 16))
return cps
# name records that carry the FAMILY NAME and must be renamed (OFL Reserved
# Font Name requirement). Copyright (0), designer/vendor credits (8,9,11,12)
# are preserved untouched - the OFL requires the copyright notice be retained.
FAMILY_NAME_IDS = (1, 3, 4, 6, 16, 17, 21, 22, 25)
OFL_ID13 = ("This Font Software is licensed under the SIL Open Font License, "
"Version 1.1.")
PROVENANCE_ID10 = ("punkdoc-mono is a glyph subset of Cascadia Mono v2404.23 "
"(c) Microsoft Corporation, renamed as required by the SIL "
"OFL 1.1 Reserved Font Name condition. Prepared for the "
"punkshell documentation pipeline.")
def rename(font):
name = font["name"]
keep = []
for rec in name.names:
# drop the trademark record entirely: the original text is about the
# Cascadia name (which this font no longer uses) and a renamed copy
# must not assert Microsoft's trademark over the new name
if rec.nameID == 7:
continue
if rec.nameID in FAMILY_NAME_IDS:
s = rec.toUnicode()
for old in SRC_FAMILY_NAMES:
s = s.replace(old, NEW_FAMILY)
s = s.replace(old.replace(" ", ""), NEW_FAMILY)
if rec.nameID == 6:
s = s.replace(" ", "")
rec.string = s
elif rec.nameID == 13:
rec.string = OFL_ID13
keep.append(rec)
name.names = keep
# embedded provenance/description (mentioning the source font as credit is
# permitted - the RFN restriction is on naming the font, not attribution)
for platformID, platEncID, langID in ((3, 1, 0x409), (1, 0, 0)):
name.setName(PROVENANCE_ID10, 10, platformID, platEncID, langID)
def normalize_line_metrics(font):
"""Make every line-metric set equal the WINDOWS metrics.
Cascadia draws its block-element glyphs to fill the windows cell
(usWinAscent + usWinDescent = 2706/2048 = 1.3213em) - that box IS the
terminal cell. But hhea/typo metrics are smaller (2380 units), and
browsers differ in which set they use for the inline content area, so
inline backgrounds and 'line-height: normal' vary per browser and can
leave uncovered bands between rows of block art.
Setting hhea == typo == win (and the USE_TYPO_METRICS flag) makes the
content area equal the block paint extent everywhere: with css
line-height <= 1.3213em, adjacent rows' inline backgrounds and block
glyphs overlap slightly and horizontal seams become impossible.
"""
hhea = font["hhea"]
os2 = font["OS/2"]
win_asc = os2.usWinAscent
win_desc = os2.usWinDescent
hhea.ascent = win_asc
hhea.descent = -win_desc
hhea.lineGap = 0
os2.sTypoAscender = win_asc
os2.sTypoDescender = -win_desc
os2.sTypoLineGap = 0
os2.fsSelection |= 0x80 # USE_TYPO_METRICS
print(f"line metrics normalized to win: ascent {win_asc} descent {win_desc} "
f"(cell {(win_asc+win_desc)/font['head'].unitsPerEm:.6f} em)")
def main():
src, unifile, outdir = sys.argv[1], sys.argv[2], sys.argv[3]
os.makedirs(outdir, exist_ok=True)
cps = parse_unicodes(unifile)
print(f"fonttools {fonttools_version}; requested codepoints: {len(cps)}")
#recalcTimestamp=False keeps the source font's head.modified so rebuilds
#from the same input are byte-reproducible
font = TTFont(src, recalcTimestamp=False)
opts = Options()
opts.layout_features = ["*"] # keep default layout behaviour
opts.name_IDs = ["*"] # keep all name records (license notice ids too)
opts.name_legacy = True
opts.name_languages = ["*"]
opts.notdef_outline = True
opts.recalc_bounds = True
opts.recalc_average_width = True
opts.prune_unicode_ranges = True
subsetter = Subsetter(options=opts)
subsetter.populate(unicodes=cps)
subsetter.subset(font)
covered = set()
for table in font["cmap"].tables:
covered.update(table.cmap.keys())
print(f"glyphs in subset: {font['maxp'].numGlyphs}; cmap codepoints: {len(covered)}")
rename(font)
# normalize_line_metrics(font) # REJECTED EXPERIMENT (2026-07-14): making
# hhea/typo equal the win metrics grows the browsers' inline content area
# to 1.3213em, so at row pitches below that every span's background bleeds
# ~2.5px into the neighbouring rows - visible as wrong-coloured bars at
# the bottom of solid blocks. Stock metrics + squashed pitch (+ optional
# scaleY for aspect) rendered best in practice. Kept for reference.
ttf_path = os.path.join(outdir, "punkdoc-mono.ttf")
font.save(ttf_path)
print(f"wrote {ttf_path} ({os.path.getsize(ttf_path)} bytes)")
font.flavor = "woff2"
woff2_path = os.path.join(outdir, "punkdoc-mono.woff2")
font.save(woff2_path)
print(f"wrote {woff2_path} ({os.path.getsize(woff2_path)} bytes)")
if __name__ == "__main__":
main()

BIN
src/scriptapps/tools/fontprep/punkdoc-mono.woff2

Binary file not shown.

21
src/scriptapps/tools/fontprep/punkdoc-unicodes.txt

@ -0,0 +1,21 @@
# punkdoc-mono subset ranges - whole blocks covering (with headroom) the
# glyph demand measured by glyph_inventory.tcl over src/testansi +
# textblock::periodic/frames + punk::args::usage (2026-07-13, 160 codepoints)
# plus the full CP437 repertoire and braille for future plot rendering.
0020-007E
00A0-00FF
0180-024F
0370-03FF
2000-206F
2070-209F
20A0-20CF
2190-21FF
2200-22FF
2300-23FF
2400-243F
2500-25FF
2600-26FF
2700-27BF
2800-28FF
1CC00-1CEBF
1FB00-1FBFF

556
src/scriptapps/tools/punkargs_punknative.tcl

@ -0,0 +1,556 @@
# punkargs_punknative.tcl - PROTOTYPE (companion to punkargs_to_doctools.tcl)
# Punk-native documentation pipeline: generate html + markdown for one package
# directly from its punk::args definitions, faithful to the punk s-style
# synopsis conventions ([...] optionals, <type> placeholders) - bypassing
# doctools entirely (doctools stays Tcl-standard; no man-page output here).
#
# usage: tclsh punkargs_punknative.tcl <package> <outdir> ?-boxmap none|light? ?-assets embed|link|none?
# -boxmap none (default): faithful heavy box-drawing glyphs
# -boxmap light: map heavy/mixed box chars to the light set (portability
# fallback for viewers with only minimal mono fonts; _lightbox suffix)
# -assets embed (default): data-uri the pinned punkdoc-mono web font
# (fontprep/punkdoc-mono.woff2) into the page - fully self-contained
# single file; every glyph (incl. octants/legacy computing) renders
# identically on any machine (~106KB page overhead)
# -assets link: reference punkdoc-mono.woff2 relatively and copy it to
# <outdir> - one cached font shared by a whole generated doc set
# -assets none: no font shipped; rely on the viewer's installed fonts
# via the css stack (octants/legacy-computing glyphs may be missing)
# writes: <outdir>/<pkg>_punknative.html terminal-faithful rendering:
# punk::args::usage ANSI output converted to styled HTML spans
# (dark terminal theme, box-drawing tables, colours, italics)
# <outdir>/<pkg>_punknative.md GitHub-flavoured markdown:
# punk-style synopsis in code fences (ansistripped - exact
# s-style text), summary/help prose, argument tables in
# argspace order (GFM tables; no colour - github sanitizes
# inline styles, so md is the plain-but-faithful skeleton)
#
# The ansi2html converter here is deliberately minimal (SGR subset punk::args
# output uses: reset/bold/dim/italic/underline/strike/reverse, 16-colour,
# 256-colour, truecolour). If adopted, it belongs in punk::ansi as a proper
# public api (would also enable 'screenshots' of arbitrary repl output in docs).
lassign [split [info tclversion] .] tcl_major tcl_minor
#script lives at <projectroot>/src/scriptapps/tools/punkargs_punknative.tcl
set project_root [file dirname [file dirname [file dirname [file dirname [file dirname [file normalize [info script]/__]]]]]]
if {![file isdirectory $project_root/src/bootsupport/modules]} {
puts stderr "cannot locate <projectroot>/src/bootsupport/modules from script location (derived project_root: $project_root)"
exit 2
}
set original_tmlist [tcl::tm::list]
tcl::tm::remove {*}$original_tmlist
tcl::tm::add [file normalize $project_root/src/bootsupport/modules]
tcl::tm::add [file normalize $project_root/src/bootsupport/modules_tcl$tcl_major]
tcl::tm::add {*}[lreverse $original_tmlist]
package require platform
set arch [platform::generic]
foreach libdir [list [file normalize $project_root/src/bootsupport/lib] [file normalize $project_root/src/bootsupport/lib/tcl$tcl_major/$arch]] {
if {$libdir ni $::auto_path} {lappend ::auto_path $libdir}
}
package require punk::args
package require punk::lib ;#punk::args::lib::tstr placeholder resolution uses punk::lib::undent
package require punk::ansi
lassign $argv pkgname outdir
#-boxmap: heavy box-drawing glyphs (U+2501 etc.) are the punk default and
#render correctly PROVIDED the css font stack is applied to the pre element
#itself (the UA stylesheet's 'pre {font-family:monospace}' overrides body
#inheritance - the original overlong-border bug). The stack includes a
#heavy-box-capable font on every mainstream platform (Consolas on windows,
#DejaVu on linux, Menlo on macos), so faithful heavy output is the default.
#-boxmap light maps heavy/mixed glyphs to the light set (universal coverage
#in even minimal mono fonts like Courier New) as a portability fallback.
#Verified 2026-07-13: heavy renders aligned in chrome + firefox with the
#pre-level font stack.
set boxmap none
set assets embed
foreach {k v} [lrange $argv 2 end] {
switch -- $k {
-boxmap {
if {$v ni {light none}} {
puts stderr "-boxmap must be light or none (got '$v')"
exit 2
}
set boxmap $v
}
-assets {
if {$v ni {embed link none}} {
puts stderr "-assets must be embed, link or none (got '$v')"
exit 2
}
set assets $v
}
default {
puts stderr "unknown option '$k' (known: -boxmap -assets)"
exit 2
}
}
}
if {$pkgname eq "" || $outdir eq ""} {
puts stderr "usage: punkargs_punknative.tcl <package> <outdir> ?-boxmap light|none?"
exit 2
}
set pkgversion [package require $pkgname]
file mkdir $outdir
# ---------------------------------------------------------------------------
# minimal ansi (SGR) -> html converter
# ---------------------------------------------------------------------------
namespace eval ansi2html {
#16-colour palette (vscode-ish terminal defaults)
variable pal16 {
#000000 #cd3131 #0dbc79 #e5e510 #2472c8 #bc3fbc #11a8cd #e5e5e5
#666666 #f14c4c #23d18b #f5f543 #3b8eea #d670d6 #29b8db #ffffff
}
variable default_fg "#d4d4d4"
#terminal backdrop for rendered blocks: BLACK. overtype::renderspace
#overlay output uses explicit SGR 40 (black bg) and art/example helpers
#treat 'default background' as the terminal's - on a black pre they blend
#exactly as in a black terminal (a grey pre showed contrasting black bars
#inside example blocks). The page body stays lighter for contrast.
variable default_bg "#000000"
proc color256 {n} {
variable pal16
if {$n < 16} {return [lindex $pal16 $n]}
if {$n <= 231} {
set n [expr {$n - 16}]
set levels {0 95 135 175 215 255}
set r [lindex $levels [expr {$n / 36}]]
set g [lindex $levels [expr {($n % 36) / 6}]]
set b [lindex $levels [expr {$n % 6}]]
return [format "#%02x%02x%02x" $r $g $b]
}
set v [expr {8 + 10 * ($n - 232)}]
return [format "#%02x%02x%02x" $v $v $v]
}
proc fresh_state {} {
return [dict create bold 0 dim 0 italic 0 underline 0 strike 0 reverse 0 fg "" bg ""]
}
#apply one SGR sequence's parameter list to a state dict
proc apply_params {state params} {
variable pal16
if {![llength $params]} {set params 0}
for {set i 0} {$i < [llength $params]} {incr i} {
set p [lindex $params $i]
#tolerate leading zeros / empty params
if {$p eq ""} {set p 0}
set p [scan $p %d]
switch -- $p {
0 {set state [fresh_state]}
1 {dict set state bold 1}
2 {dict set state dim 1}
3 {dict set state italic 1}
4 {dict set state underline 1}
7 {dict set state reverse 1}
9 {dict set state strike 1}
22 {dict set state bold 0; dict set state dim 0}
23 {dict set state italic 0}
24 {dict set state underline 0}
27 {dict set state reverse 0}
29 {dict set state strike 0}
39 {dict set state fg ""}
49 {dict set state bg ""}
38 - 48 {
set target [expr {$p == 38 ? "fg" : "bg"}]
set mode [lindex $params [incr i]]
if {$mode == 5} {
dict set state $target [color256 [lindex $params [incr i]]]
} elseif {$mode == 2} {
set r [lindex $params [incr i]]
set g [lindex $params [incr i]]
set b [lindex $params [incr i]]
dict set state $target [format "#%02x%02x%02x" $r $g $b]
}
}
default {
if {$p >= 30 && $p <= 37} {
dict set state fg [lindex $pal16 [expr {$p - 30}]]
} elseif {$p >= 90 && $p <= 97} {
dict set state fg [lindex $pal16 [expr {$p - 90 + 8}]]
} elseif {$p >= 40 && $p <= 47} {
dict set state bg [lindex $pal16 [expr {$p - 40}]]
} elseif {$p >= 100 && $p <= 107} {
dict set state bg [lindex $pal16 [expr {$p - 100 + 8}]]
}
#other params ignored
}
}
}
return $state
}
proc state_css {state} {
variable default_fg
variable default_bg
set fg [dict get $state fg]
set bg [dict get $state bg]
if {[dict get $state reverse]} {
lassign [list [expr {$bg eq "" ? $default_bg : $bg}] [expr {$fg eq "" ? $default_fg : $fg}]] fg bg
}
set css [list]
if {$fg ne ""} {lappend css "color:$fg"}
if {$bg ne ""} {lappend css "background-color:$bg"}
if {[dict get $state bold]} {lappend css "font-weight:bold"}
if {[dict get $state dim]} {lappend css "opacity:.62"}
if {[dict get $state italic]} {lappend css "font-style:italic"}
set deco [list]
if {[dict get $state underline]} {lappend deco underline}
if {[dict get $state strike]} {lappend deco line-through}
if {[llength $deco]} {lappend css "text-decoration:[join $deco { }]"}
return [join $css ";"]
}
proc html_escape {text} {
return [string map {& &amp; < &lt; > &gt;} $text]
}
#map heavy and mixed-weight box-drawing chars to the light set (universal
#monospace font coverage - avoids browser glyph-fallback width blowout)
variable lightboxmap {
}
proc to_lightbox {text} {
variable lightboxmap
return [string map $lightboxmap $text]
}
#convert ANSI text to html (span-styled, for use inside <pre>)
proc convert {text} {
set out ""
set state [fresh_state]
#split_codes returns alternating plaintext,codes,plaintext,... (codes
#element may contain several adjacent escape sequences)
set parts [punk::ansi::ta::split_codes $text]
set is_pt 1
foreach part $parts {
if {$is_pt} {
if {$part ne ""} {
set css [state_css $state]
if {$css ne ""} {
append out "<span style=\"$css\">[html_escape $part]</span>"
} else {
append out [html_escape $part]
}
}
} else {
#apply each SGR sequence in the code chunk; ignore non-SGR codes
foreach {m body} [regexp -all -inline {\x1b\[([0-9;:]*)m} $part] {
set state [apply_params $state [split [string map {: ;} $body] \;]]
}
}
set is_pt [expr {!$is_pt}]
}
return $out
}
}
# ---------------------------------------------------------------------------
# shared spec helpers (subset of the doctools converter's)
# ---------------------------------------------------------------------------
proc optvalname {optname} {return [string trimleft $optname -]}
proc is_solo {arginfo} {
return [expr {"none" in [split [lindex [dict get $arginfo -type] 0] |]}]
}
proc typedisplay {arginfo} {
set t [dict get $arginfo -type]
return [lindex [split [lindex $t 0] |] 0]
}
proc md_escape {text} {
#escape for GFM table cells: pipes and newlines
set text [punk::ansi::ansistrip $text]
set text [string map {| \\| \r\n " " \n " " \r " "} $text]
return $text
}
proc md_prose {text} {
#reflow to paragraphs: source indentation must not survive - 4-space
#indented lines are code blocks in markdown (accidental <pre> rendering)
set text [punk::ansi::ansistrip $text]
set paras [list]
set current ""
foreach ln [split $text \n] {
if {[string trim $ln] eq ""} {
if {$current ne ""} {lappend paras $current; set current ""}
} else {
append current [string trim $ln] " "
}
}
if {$current ne ""} {lappend paras $current}
return [join $paras "\n\n"]
}
#punk s-style synopsis text (ansistripped), minus the '# summary' preamble line;
#'## FORM n' headers kept only for multi-form commands
proc punk_synopsis_text {id nforms} {
set s [punk::ansi::ansistrip [punk::args::synopsis $id]]
set keep [list]
foreach ln [split $s \n] {
if {[string match "# *" $ln]} {continue}
if {[string match "## FORM*" $ln] && $nforms < 2} {continue}
lappend keep $ln
}
return [join $keep \n]
}
# ---------------------------------------------------------------------------
# gather
# ---------------------------------------------------------------------------
punk::args::update_definitions [list ::$pkgname]
set ids [lsort [punk::args::get_ids ::${pkgname}::*]]
set cmd_ids [list]
foreach id $ids {
set tail [string range $id [string length ::${pkgname}::] end]
if {[string first :: $tail] < 0} {lappend cmd_ids $id}
}
if {![llength $cmd_ids]} {
puts stderr "no punk::args ids found for ::${pkgname}::*"
exit 1
}
#suffix distinguishes these from doctools-engine renderings of the same package
set pkgfile "[string map {:: _} $pkgname]_punknative"
if {$boxmap eq "light"} {
append pkgfile "_lightbox"
}
puts "punk-native docgen: [llength $cmd_ids] command definitions for $pkgname $pkgversion"
# ---------------------------------------------------------------------------
# html: terminal-faithful - ansi2html of punk::args::usage per command
# ---------------------------------------------------------------------------
#pinned web font (see fontprep/README.md): guarantees every glyph in the punk
#domain - incl. unicode 16 octants and legacy computing blocks that no
#commonly-installed font provides. Variable (wght 200-700) so bold spans use
#real bold. OFL-subset of Cascadia Mono renamed punkdoc-mono (RFN condition).
set fontfile [file join [file dirname [file dirname [file normalize [info script]/__]]] fontprep punkdoc-mono.woff2]
set fontface ""
if {$assets ne "none"} {
if {![file exists $fontfile]} {
puts stderr "WARNING: -assets $assets requested but $fontfile not found - falling back to -assets none"
set assets none
} else {
if {$assets eq "embed"} {
set fd [open $fontfile rb]
set fontb64 [binary encode base64 [read $fd]]
close $fd
set fontsrc "data:font/woff2;base64,$fontb64"
} else {
#link mode: ship the font next to the page(s) - shared+cached
file copy -force $fontfile [file join $outdir punkdoc-mono.woff2]
set fontsrc "punkdoc-mono.woff2"
}
set fontface "@font-face {font-family:\"punkdoc-mono\"; src:url($fontsrc) format(\"woff2\"); font-weight:200 700; font-display:block;}\n"
}
}
set html ""
append html "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">\n"
append html "<title>$pkgname $pkgversion - punk::args reference</title>\n"
append html "<style>\n"
append html $fontface
#fallback stack: fonts with FULL box-drawing coverage (incl. heavy +
#mixed-weight chars) first - Courier New (firefox's default mono) lacks the
#heavy set. lang=en above steers chrome away from CJK ambiguous-width
#(double-width) fallback for box chars when no stack font matches.
set fontstack "'Cascadia Mono','Cascadia Code','DejaVu Sans Mono','Noto Sans Mono','JetBrains Mono','Liberation Mono',Consolas,Menlo,monospace"
if {$fontface ne ""} {
set fontstack "'punkdoc-mono',$fontstack"
}
append html "body {background:#1e1e1e; color:$ansi2html::default_fg; font-family:$fontstack;}\n"
append html "h1,h2,h3 {font-family:inherit; color:#e5e510;}\n"
append html "nav.cmdindex {background:#000; padding:8px 12px; display:inline-block;}\n"
append html "nav.cmdindex a {color:#29b8db; text-decoration:none; display:inline-block; margin-right:1.5em;}\n"
append html "nav.cmdindex a:hover {text-decoration:underline;}\n"
#IMPORTANT: pre needs its own font-family - the UA stylesheet's
#'pre {font-family:monospace}' overrides inheritance from body, so without
#this the pre renders in the browser's default mono font regardless of the
#body stack (the original cause of overlong heavy-box border lines)
#punkdoc-mono cell geometry (see fontprep/README.md): advance is exactly
#0.5859375em - font-size 15.36px gives an integer 9px cell width (fractional
#advances round differently at styled-span boundaries -> vertical seams
#through block/border glyphs). Row pitch: browsers size inline BACKGROUNDS
#to the font's hhea content area = 1.1621em = 17.85px here; a fractional
#pitch (1.15em = 17.664px) put row boundaries at fractional positions and
#showed periodic hairline gaps through solid-bg runs (~every 2 lines).
#line-height:17px = integer pitch just under the bg height, so adjacent
#rows' backgrounds overlap ~0.85px - sealed at any zoom/dpi (cost: bg rows
#overpaint <1px of the row above's descenders - imperceptible in tables).
#If the font stack falls back past punkdoc-mono this exactness is lost but
#text-only content tolerates it.
append html "pre.punkterm {font-family:$fontstack; line-height:17px; font-size:15.36px; overflow-x:auto; padding:8px; background:$ansi2html::default_bg; font-variant-ligatures:none;}\n"
append html "hr {border:0; border-top:1px solid #444;}\n"
append html "</style></head><body>\n"
append html "<h1>$pkgname $pkgversion</h1>\n"
append html "<p>Command reference generated from punk::args definitions (terminal-faithful rendering).</p>\n"
#module/package-level info when @package directives are registered (none in
#older module versions - the hook is here for when dev modules surface them)
set package_notes [list]
foreach id $cmd_ids {
set spec [punk::args::get_spec $id]
#package_info key absent in older punk::args spec dicts (pre-@package)
if {![dict exists $spec package_info]} {break}
set pinfo [dict get $spec package_info]
if {[llength $pinfo] && $pinfo ni $package_notes} {
lappend package_notes $pinfo
}
}
foreach pinfo $package_notes {
append html "<p>package: [ansi2html::html_escape [punk::ansi::ansistrip $pinfo]]</p>\n"
}
#command index navigation (doctools-style jump list)
append html "<nav class=\"cmdindex\">\n"
foreach id $cmd_ids {
set cmdname [string trimleft $id :]
set esc [ansi2html::html_escape $cmdname]
append html "<a href=\"#$esc\">$esc</a>\n"
}
append html "</nav>\n"
#helper: one converted usage block.
#-trim 1: keep only the Arg table (from its header separator down) - used for
#per-form blocks so they don't repeat the whole command/description/synopsis
#panel and drown the per-form differences (renderspace output lines are
#colour-self-contained, so slicing at a line boundary is safe)
proc usage_block {id args} {
global boxmap
set trim 0
set idx [lsearch -exact $args -trim]
if {$idx >= 0} {
set trim [lindex $args $idx+1]
set args [lreplace $args $idx $idx+1]
}
if {[catch {punk::args::usage {*}$args $id} u]} {
return "<p>(no usage rendering: [ansi2html::html_escape $u])</p>\n"
}
if {$trim} {
set lines [split $u \n]
set argidx -1
set i 0
foreach ln $lines {
if {[string match "Arg *" [punk::ansi::ansistrip $ln]]} {
set argidx $i
break
}
incr i
}
if {$argidx > 0} {
#include the separator line above the Arg header as the top border
set u [join [lrange $lines $argidx-1 end] \n]
}
}
if {$boxmap eq "light"} {
set u [ansi2html::to_lightbox $u]
}
return "<pre class=\"punkterm\">[ansi2html::convert $u]</pre>\n"
}
foreach id $cmd_ids {
set cmdname [string trimleft $id :]
append html "<hr><h2 id=\"[ansi2html::html_escape $cmdname]\">$cmdname</h2>\n"
set aliastarget [punk::args::get_idalias $id]
if {$aliastarget ne ""} {
append html "<p>Takes the same arguments as <b>[ansi2html::html_escape [string trimleft $aliastarget :]]</b> (shared argument definition).</p>\n"
}
#the default usage table shows description + all form synopses, but for
#multiform commands its Arg table covers only the default form - render
#an additional per-form ARG TABLE (trimmed - no repeated header panel)
#for each form that has arguments (mirrors 'i -form N <cmd>').
append html [usage_block $id]
set spec [punk::args::get_spec $id]
set formnames [dict get $spec form_names]
if {[llength $formnames] > 1} {
foreach formname $formnames {
set form [dict get $spec FORMS $formname]
if {![dict size [dict get $form ARG_INFO]]} {
#form takes no arguments - the synopsis line says it all
continue
}
append html "<h3>form: [ansi2html::html_escape $formname]</h3>\n"
append html [usage_block $id -form $formname -trim 1]
}
}
}
append html "</body></html>\n"
set fd [open [file join $outdir $pkgfile.html] w]
fconfigure $fd -translation lf -encoding utf-8
puts -nonewline $fd $html
close $fd
puts "wrote [file join $outdir $pkgfile.html] ([string length $html] bytes)"
# ---------------------------------------------------------------------------
# markdown: GFM - punk-style synopsis fences + argument tables (argspace order)
# (-boxmap only affects the html rendering - only the default run writes md,
# so an opt-in heavybox run doesn't duplicate an identical file)
# ---------------------------------------------------------------------------
if {$boxmap ne "none"} {
exit 0
}
proc md_argrow {argname arginfo} {
set type [md_escape [typedisplay $arginfo]]
if {[is_solo $arginfo]} {set type "(solo flag)"}
set default ""
if {[dict exists $arginfo -default]} {set default [md_escape [dict get $arginfo -default]]}
if {$default eq ""} {set default " "}
set multi " "
if {[dict exists $arginfo -multiple] && [dict get $arginfo -multiple]} {set multi "yes"}
set help ""
if {[dict exists $arginfo -help]} {set help [md_escape [dict get $arginfo -help]]}
set choices [list]
if {[dict exists $arginfo -choices]} {lappend choices {*}[dict get $arginfo -choices]}
if {[dict exists $arginfo -choicegroups]} {
dict for {grp members} [dict get $arginfo -choicegroups] {lappend choices {*}$members}
}
if {[llength $choices]} {
append help " Choices: [md_escape [join $choices {, }]]."
}
return "| `[md_escape $argname]` | $type | `$default` | $multi | $help |"
}
set md ""
append md "# $pkgname $pkgversion\n\n"
append md "Command reference generated from punk::args definitions (punk s-style synopses).\n"
foreach id $cmd_ids {
set spec [punk::args::get_spec $id]
set cmd_info [dict get $spec cmd_info]
set cmdname [string trimleft $id :]
append md "\n---\n\n## $cmdname\n\n"
set aliastarget [punk::args::get_idalias $id]
if {[dict exists $cmd_info -summary] && [dict get $cmd_info -summary] ne ""} {
append md "*[md_escape [dict get $cmd_info -summary]]*\n\n"
}
set nforms [llength [dict get $spec form_names]]
append md "```\n[punk_synopsis_text $id $nforms]\n```\n"
if {$aliastarget ne ""} {
set atail [string trimleft $aliastarget :]
set aanchor [string map {:: -} [string tolower $atail]]
append md "\nTakes the same arguments as \[`$atail`\](#$aanchor) (shared argument definition).\n"
continue
}
if {[dict exists $cmd_info -help] && [dict get $cmd_info -help] ne ""} {
append md "\n[md_prose [dict get $cmd_info -help]]\n"
}
foreach formname [dict get $spec form_names] {
set form [dict get $spec FORMS $formname]
if {$nforms > 1} {
append md "\n**form: $formname**\n"
}
set leaders [dict get $form LEADER_NAMES]
set opts [dict get $form OPT_NAMES]
set vals [dict get $form VAL_NAMES]
if {[llength $leaders] + [llength $opts] + [llength $vals] == 0} {continue}
append md "\n| Arg | Type | Default | Multi | Help |\n"
append md "| --- | --- | --- | --- | --- |\n"
#argspace order: leaders, options, trailing values
foreach argname $leaders {
append md [md_argrow $argname [dict get $form ARG_INFO $argname]] \n
}
foreach optname $opts {
append md [md_argrow $optname [dict get $form ARG_INFO $optname]] \n
}
foreach argname $vals {
append md [md_argrow $argname [dict get $form ARG_INFO $argname]] \n
}
}
}
set fd [open [file join $outdir $pkgfile.md] w]
fconfigure $fd -translation lf -encoding utf-8
puts -nonewline $fd $md
close $fd
puts "wrote [file join $outdir $pkgfile.md] ([string length $md] bytes)"

318
src/scriptapps/tools/punkargs_to_doctools.tcl

@ -0,0 +1,318 @@
# punkargs_to_doctools.tcl - PROTOTYPE (G-? candidate: punk::args as doc source of truth)
# Generate a doctools .man page for one package from its punk::args definitions.
#
# usage: tclsh punkargs_to_doctools.tcl <package> <outfile.man>
# e.g: tclsh punkargs_to_doctools.tcl punk::path punk_path.man
#
# Output is deliberately Tcl-standard doctools style ([arg]/[opt] markup; engines
# render optionals as ?...? and placeholders italic). Rendering the punk::args
# s-style synopsis conventions ([...] optionals, <type> placeholders, ANSI
# styling) is out of scope for the doctools target - doctools engines hardcode
# the Tcl conventions and cannot carry ANSI. Punk-style output belongs to a
# separate direct html/markdown pipeline (see punkargs_to_html/markdown
# prototypes and the assessment discussion 2026-07-13).
#
# Trial-quality notes (2026-07-13):
# - runs against the bootsupport modules (like src/tests/runtests.tcl toplevel)
# - covers: @cmd summary/help, per-form [call] synopses (leaders/opts/values,
# solo flags, -multiple, -optional), argument detail lists in argspace order
# (leaders, then options, then trailing values - matching the synopsis and the
# punk::args usage table) with -default/-choices/-choicegroups/
# -choicerestricted/-choiceprefix/-minsize/-maxsize/-range constraint
# sentences, id aliases (cross-reference entry), @cmd keywords accumulation
# - prose is ansistripped (punk::args -help fields can contain literal ESC from
# %B%/%I% define-time maps) and [ ]-escaped to [lb]/[rb]
# - NOT yet covered: @examples -> [example], @seealso/@doc -> [see_also]/[uri],
# choicelabels as sub-lists, grouped choice display, tables (doctools has
# none), colour (doctools has none)
lassign [split [info tclversion] .] tcl_major tcl_minor
#script lives at <projectroot>/src/scriptapps/tools/punkargs_to_doctools.tcl
set project_root [file dirname [file dirname [file dirname [file dirname [file dirname [file normalize [info script]/__]]]]]]
if {![file isdirectory $project_root/src/bootsupport/modules]} {
puts stderr "cannot locate <projectroot>/src/bootsupport/modules from script location (derived project_root: $project_root)"
exit 2
}
set original_tmlist [tcl::tm::list]
tcl::tm::remove {*}$original_tmlist
tcl::tm::add [file normalize $project_root/src/bootsupport/modules]
tcl::tm::add [file normalize $project_root/src/bootsupport/modules_tcl$tcl_major]
tcl::tm::add {*}[lreverse $original_tmlist]
package require platform
set arch [platform::generic]
foreach libdir [list [file normalize $project_root/src/bootsupport/lib] [file normalize $project_root/src/bootsupport/lib/tcl$tcl_major/$arch]] {
if {$libdir ni $::auto_path} {lappend ::auto_path $libdir}
}
package require punk::args
package require punk::ansi
lassign $argv pkgname outfile
if {$pkgname eq "" || $outfile eq ""} {
puts stderr "usage: punkargs_to_doctools.tcl <package> <outfile.man>"
exit 2
}
set pkgversion [package require $pkgname]
namespace eval argdoc2man {
#escape doctools-special brackets in prose; strip ANSI (punk::args -help fields
#may contain literal ESC sequences from %B%/%I% define-time maps)
proc prose {text} {
set text [punk::ansi::ansistrip $text]
return [string map {[ [lb] ] [rb]} $text]
}
#multi-line help -> doctools flowed text with [para] breaks on blank lines
proc prose_paras {text} {
set text [prose $text]
set paras [list]
set current ""
foreach ln [split $text \n] {
if {[string trim $ln] eq ""} {
if {$current ne ""} {lappend paras $current; set current ""}
} else {
append current [string trim $ln] " "
}
}
if {$current ne ""} {lappend paras $current}
return [join $paras "\n\[para\]\n"]
}
#display name for the value an option takes, from its argname
proc optvalname {optname} {
return [string trimleft $optname -]
}
#primary type word for display (first alternative, first clause member)
proc typedisplay {arginfo} {
set t [dict get $arginfo -type]
set t0 [lindex $t 0]
set t0 [lindex [split $t0 |] 0]
return $t0
}
proc is_solo {arginfo} {
return [expr {"none" in [split [lindex [dict get $arginfo -type] 0] |]}]
}
#synopsis markup for one positional (leader or value) argument
proc positional_markup {form argname} {
set ainfo [dict get $form ARG_INFO $argname]
set a "\[arg [list $argname]\]"
if {[dict exists $ainfo -multiple] && [dict get $ainfo -multiple]} {
append a " \[opt \[arg [list $argname...]\]\]"
}
if {[dict exists $ainfo -optional] && [dict get $ainfo -optional]} {
set a "\[opt \"$a\"\]"
}
return $a
}
#build the [call ...] synopsis markup for one form
proc call_markup {cmdname form} {
set bits [list]
foreach argname [dict get $form LEADER_NAMES] {
lappend bits [positional_markup $form $argname]
}
foreach optname [dict get $form OPT_NAMES] {
set ainfo [dict get $form ARG_INFO $optname]
if {[is_solo $ainfo]} {
set a "\[opt \[option [list $optname]\]\]"
} else {
set a "\[opt \"\[option [list $optname]\] \[arg [list [optvalname $optname]]\]\"\]"
}
lappend bits $a
}
foreach argname [dict get $form VAL_NAMES] {
lappend bits [positional_markup $form $argname]
}
return "\[call \[cmd [list $cmdname]\] [join $bits { }]\]"
}
#constraint sentences for one argument from its resolved ARG_INFO entry
proc constraint_sentences {arginfo isopt} {
set out [list]
if {[dict exists $arginfo -default]} {
set def [dict get $arginfo -default]
if {$def eq ""} {set def "(empty)"}
lappend out "Defaults to '[prose $def]'."
}
set choices [list]
if {[dict exists $arginfo -choices]} {lappend choices {*}[dict get $arginfo -choices]}
if {[dict exists $arginfo -choicegroups]} {
dict for {grp members} [dict get $arginfo -choicegroups] {lappend choices {*}$members}
}
if {[llength $choices]} {
set restricted 1
if {[dict exists $arginfo -choicerestricted]} {set restricted [dict get $arginfo -choicerestricted]}
set s "Choices: [prose [join $choices {, }]]"
if {!$restricted} {append s " (other values accepted)"}
if {[dict exists $arginfo -choiceprefix] && [dict get $arginfo -choiceprefix]} {
append s " (unique prefixes accepted)"
}
lappend out "$s."
}
if {[dict exists $arginfo -multiple] && [dict get $arginfo -multiple]} {
if {$isopt} {
lappend out "May be given multiple times."
} else {
lappend out "Accepts multiple values."
}
}
foreach {k label} {-minsize "Minimum size" -maxsize "Maximum size" -range "Range"} {
if {[dict exists $arginfo $k]} {
lappend out "$label: [prose [dict get $arginfo $k]]."
}
}
return [join $out " "]
}
#help + constraint body for one argument's detail entry
proc argitem_body {arginfo isopt} {
set body ""
if {[dict exists $arginfo -help]} {
set body [prose_paras [dict get $arginfo -help]]
}
set extra [constraint_sentences $arginfo $isopt]
if {$extra ne ""} {
if {$body ne ""} {append body "\n\[para\]\n"}
append body $extra
}
if {$body eq ""} {set body "No description."}
return $body
}
#argument detail lists for one form, in argspace order: leaders, options,
#trailing values (matching the synopsis and the punk::args usage table).
#Group headings are emitted only when more than one group is present.
proc arg_details {form} {
set out ""
set leaders [dict get $form LEADER_NAMES]
set opts [dict get $form OPT_NAMES]
set vals [dict get $form VAL_NAMES]
set ngroups [expr {([llength $leaders]>0) + ([llength $opts]>0) + ([llength $vals]>0)}]
if {[llength $leaders]} {
if {$ngroups > 1} {
append out "\[para\]\[emph {Leading arguments:}\]\n"
}
append out "\[list_begin arguments\]\n"
foreach argname $leaders {
set ainfo [dict get $form ARG_INFO $argname]
append out "\[arg_def [list [typedisplay $ainfo]] [list $argname]\]\n"
append out [argitem_body $ainfo 0] \n
}
append out "\[list_end\]\n"
}
if {[llength $opts]} {
if {$ngroups > 1} {
append out "\[para\]\[emph {Options:}\]\n"
}
append out "\[list_begin options\]\n"
foreach optname $opts {
set ainfo [dict get $form ARG_INFO $optname]
if {[is_solo $ainfo]} {
append out "\[opt_def [list $optname]\]\n"
} else {
append out "\[opt_def [list $optname] \[arg [list [optvalname $optname]]\]\]\n"
}
append out [argitem_body $ainfo 1] \n
}
append out "\[list_end\]\n"
}
if {[llength $vals]} {
if {$ngroups > 1} {
append out "\[para\]\[emph {Trailing values:}\]\n"
}
append out "\[list_begin arguments\]\n"
foreach argname $vals {
set ainfo [dict get $form ARG_INFO $argname]
append out "\[arg_def [list [typedisplay $ainfo]] [list $argname]\]\n"
append out [argitem_body $ainfo 0] \n
}
append out "\[list_end\]\n"
}
return $out
}
}
#---- gather definitions for the package ------------------------------------
punk::args::update_definitions [list ::$pkgname]
set ids [lsort [punk::args::get_ids ::${pkgname}::*]]
#keep the page focused on the package's direct commands: skip ids with further
#:: segments below the package namespace (sub-ensembles could get their own pages)
set cmd_ids [list]
foreach id $ids {
set tail [string range $id [string length ::${pkgname}::] end]
if {[string first :: $tail] < 0} {
lappend cmd_ids $id
}
}
if {![llength $cmd_ids]} {
puts stderr "no punk::args ids found for ::${pkgname}::*"
exit 1
}
puts "converting [llength $cmd_ids] command definitions for package $pkgname $pkgversion"
#---- emit doctools ----------------------------------------------------------
set man ""
append man "\[comment {--- generated from punk::args definitions by punkargs_to_doctools.tcl (prototype) - do not edit ---}\]\n"
append man "\[manpage_begin [list $pkgname] n [list $pkgversion]\]\n"
append man "\[moddesc {punkshell - generated argdoc}\]\n"
append man "\[titledesc {Command reference for [list $pkgname] (generated from punk::args definitions)}\]\n"
append man "\[require [list $pkgname] \[opt [list $pkgversion]\]\]\n"
append man "\[description\]\n"
append man "\[para\] Command reference for package [list $pkgname], generated from its punk::args runtime argument definitions.\n"
append man "\[section Commands\]\n"
append man "\[list_begin definitions\]\n"
set all_keywords [list]
foreach id $cmd_ids {
set spec [punk::args::get_spec $id]
set cmd_info [dict get $spec cmd_info]
set cmdname [string trimleft $id :]
#an id alias is a distinct command sharing another command's definition
#(e.g punk::path::treefilenames_zipfs -> punk::path::treefilenames):
#emit its own synopsis under its own name with a cross-reference body,
#not a full duplicate of the target's documentation
set aliastarget [punk::args::get_idalias $id]
if {$aliastarget ne ""} {
foreach formname [dict get $spec form_names] {
set form [dict get $spec FORMS $formname]
append man [argdoc2man::call_markup $cmdname $form] \n
}
if {[dict exists $cmd_info -summary] && [dict get $cmd_info -summary] ne ""} {
append man "\[emph {[argdoc2man::prose [dict get $cmd_info -summary]]}\]\n\[para\]\n"
}
append man "Takes the same arguments as \[cmd [list [string trimleft $aliastarget :]]\] (shared argument definition) - see above/below for details.\n"
continue
}
if {[dict exists $cmd_info -name]} {
set cmdname [dict get $cmd_info -name]
}
#one [call] per form
foreach formname [dict get $spec form_names] {
set form [dict get $spec FORMS $formname]
append man [argdoc2man::call_markup $cmdname $form] \n
}
#summary + main help
if {[dict exists $cmd_info -summary] && [dict get $cmd_info -summary] ne ""} {
append man "\[emph {[argdoc2man::prose [dict get $cmd_info -summary]]}\]\n\[para\]\n"
}
if {[dict exists $cmd_info -help] && [dict get $cmd_info -help] ne ""} {
append man [argdoc2man::prose_paras [dict get $cmd_info -help]] \n "\[para\]\n"
}
#argument details per form
set formnames [dict get $spec form_names]
foreach formname $formnames {
set form [dict get $spec FORMS $formname]
if {[llength $formnames] > 1} {
append man "\[para\]\[emph \"form: [argdoc2man::prose $formname]\"\]\n"
}
append man [argdoc2man::arg_details $form]
}
#seealso/keywords accumulate to page level
foreach kw [dict get $spec keywords_info] {
if {[dict exists $kw -name]} {lappend all_keywords [dict get $kw -name]}
}
}
append man "\[list_end\]\n"
if {[llength $all_keywords]} {
append man "\[keywords [join [lsort -unique $all_keywords] { }]\]\n"
}
append man "\[manpage_end\]\n"
set fd [open $outfile w]
fconfigure $fd -translation lf
puts -nonewline $fd $man
close $fd
puts "wrote $outfile ([string length $man] bytes)"

195
src/tests/modules/punk/args/testsuites/args/formcheck.test

@ -0,0 +1,195 @@
package require tcltest
package require punk::args
#G-074: punk::args::formcheck - on-demand multiform ambiguity analysis with
#sanctioned-overlap annotation (@form -overlapallowed).
#A finding is only reported after a synthetic witness arglist is CONFIRMED by a real
#single-form parse against both forms, so fully discriminated form pairs cannot
#false-alarm; witness derivation is the documented miss direction (exotic types,
#forms requiring options, capped -multiple repetition). The sanction is
#formcheck-reporting metadata only - parse behaviour (multipleformmatches) is
#unaffected. The tclcore ::lseq and ::after models are the motivating real cases
#from the G-041 closeout (goals/archive/G-041-punkargs-form-matching.md).
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
testConstraint have_tclcoredocs [expr {![catch {package require punk::args::moduledoc::tclcore}]}]
#discriminated multiform fixtures - literal/choice/int leading slots (no permissive
#alignment) and arity-window separation. formcheck must report NOTHING for these.
punk::args::define {
@id -id ::testspace::fc_afterish
@cmd -name testspace::fc_afterish -summary "after-like multiform" -help "discriminated multiform fixture"
@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
}
punk::args::define {
@id -id ::testspace::fc_sharedform
@cmd -name testspace::fc_sharedform -summary "shared-prologue multiform" -help "arity-discriminated multiform fixture"
@form -form {get set}
@leaders -min 1 -max 1
key -type string
@form -form get
@values -min 0 -max 0
@form -form set
@values -min 1 -max 1
newvalue -type string
}
#overlapping fixture: aa's typed slot faces bb's permissive slot (type-weakness),
#with the pair sanctioned on ONE member via @form -overlapallowed
punk::args::define {
@id -id ::testspace::fc_sanctioned
@cmd -name testspace::fc_sanctioned -summary "sanctioned overlap" -help "sanctioned overlapping multiform fixture"
@form -form go -overlapallowed {run}
@values -min 2 -max 2
go -type literal(go)
target -type string
@form -form run
@values -min 2 -max 2
mode -type any
target -type string
}
test formcheck_discriminated_no_findings {formcheck reports nothing for fully discriminated multiform definitions (fixtures + the parse withid/withdef pair)}\
-setup $common -body {
foreach id {::testspace::fc_afterish ::testspace::fc_sharedform ::punk::args::parse} {
set r [punk::args::formcheck $id]
lappend result [list [dict size [dict get $r findings]] [dict get $r unsanctioned]]
}
set result
}\
-cleanup {
}\
-result [list {0 {}} {0 {}} {0 {}}]
test formcheck_finding_classes_and_sanction {an overlap is classified (type-weakness = discriminator vs permissive) and -overlapallowed on one member downgrades the pair to sanctioned}\
-setup $common -body {
set r [punk::args::formcheck ::testspace::fc_sanctioned]
set finding [dict get $r findings {go run}]
lappend result [dict get $finding class]
lappend result [dict get $finding sanctioned]
lappend result [dict get $r unsanctioned]
#the reported witness genuinely parses against both forms
lappend result [dict get [punk::args::parse_status [dict get $finding witness] -form go withid ::testspace::fc_sanctioned] ok]
lappend result [dict get [punk::args::parse_status [dict get $finding witness] -form run withid ::testspace::fc_sanctioned] ok]
}\
-cleanup {
}\
-result [list type_weakness 1 {} 1 1]
test formcheck_overlapallowed_unknown_form_rejected {@form -overlapallowed naming an unknown form is rejected when the definition resolves}\
-setup $common -body {
punk::args::define {
@id -id ::testspace::fc_badsanction
@form -form aa -overlapallowed {nosuchform}
@values -min 1 -max 1
x -type string
@form -form bb
@values -min 1 -max 1
y -type any
}
if {[catch {punk::args::get_spec ::testspace::fc_badsanction} errmsg]} {
lappend result [string match "*-overlapallowed*unknown form 'nosuchform'*" $errmsg]
} else {
lappend result UNEXPECTED-resolved
}
set result
}\
-cleanup {
catch {punk::args::undefine ::testspace::fc_badsanction 1}
}\
-result [list 1]
test formcheck_return_summary {-return summary returns the human report text only}\
-setup $common -body {
set s [punk::args::formcheck -return summary ::testspace::fc_sanctioned]
lappend result [string match "punk::args::formcheck ::testspace::fc_sanctioned:*" $s]
lappend result [string match "*sanctioned type_weakness overlap: go vs run*" $s]
#dict return carries the same text under the summary key
lappend result [expr {$s eq [dict get [punk::args::formcheck ::testspace::fc_sanctioned] summary]}]
}\
-cleanup {
}\
-result [list 1 1 1]
test formcheck_lseq_typeweakness {tclcore ::lseq reports the range/start_count expr-typed-end overlap as an unsanctioned type-weakness finding ('lseq 1 count 5' class)}\
-constraints have_tclcoredocs\
-setup $common -body {
set r [punk::args::formcheck ::lseq]
set finding [dict get $r findings {range start_count}]
lappend result [dict get $finding class]
lappend result [dict get $finding sanctioned]
lappend result [expr {{range start_count} in [dict get $r unsanctioned]}]
#the witness word for the aligned discriminator slot is start_count's
#literalprefix(count) word, swallowed by range's number|expr end slot
set discword ""
foreach slot [dict get $finding slots] {
if {[dict get $slot relation] eq "discriminator_vs_permissive"} {
set discword [dict get $slot word]
}
}
lappend result $discword
#every reported ::lseq witness genuinely parses against both its forms
dict for {pairkey pf} [dict get $r findings] {
lassign $pairkey fa fb
if {![dict get [punk::args::parse_status [dict get $pf witness] -form $fa withid ::lseq] ok]
|| ![dict get [punk::args::parse_status [dict get $pf witness] -form $fb withid ::lseq] ok]} {
lappend result [list unconfirmed-witness $pairkey]
}
}
set result
}\
-cleanup {
}\
-result [list type_weakness 0 1 count]
test formcheck_after_cancel_sanctioned {tclcore ::after reports exactly the documented cancelid/cancelscript overlap - structural class, sanctioned via -overlapallowed, leaving no unsanctioned findings}\
-constraints have_tclcoredocs\
-setup $common -body {
set r [punk::args::formcheck ::after]
lappend result [dict keys [dict get $r findings]]
lappend result [dict get $r findings {cancelid cancelscript} class]
lappend result [dict get $r findings {cancelid cancelscript} sanctioned]
lappend result [dict get $r unsanctioned]
}\
-cleanup {
}\
-result [list {{cancelid cancelscript}} structural 1 {}]
test formcheck_sanction_does_not_affect_parse {the -overlapallowed sanction never changes parse behaviour - an ambiguous 'after cancel <id-shaped>' still raises multipleformmatches}\
-constraints have_tclcoredocs\
-setup $common -body {
if {[catch {punk::args::parse {cancel after#1} withid ::after} errmsg erroropts]} {
set classinfo [lindex [dict get $erroropts -errorcode] 2]
lappend result [lindex $classinfo 0]
} else {
lappend result UNEXPECTED-parsed
}
set result
}\
-cleanup {
}\
-result [list multipleformmatches]
#fixture cleanup
catch {punk::args::undefine ::testspace::fc_afterish 1}
catch {punk::args::undefine ::testspace::fc_sharedform 1}
catch {punk::args::undefine ::testspace::fc_sanctioned 1}
}
tcltest::cleanupTests ;#needed to produce test summary line.

43
src/tests/modules/punk/lib/testsuites/lib/checkbugs.test

@ -49,6 +49,46 @@ namespace eval ::testspace {
}\
-result [list 1 1 1 1 1 1 1]
test tclbug_console_deadspin_classifier {platform/tclversion/fixed_in combinations for the tcl9 dead-console defect (G-076)}\
-setup $common -body {
#affected: windows tcl 9, no fixed release known (empty fixed_in)
lappend result [punk::lib::check::tclbug_console_deadspin_applies windows 9.0.2 ""]
lappend result [punk::lib::check::tclbug_console_deadspin_applies windows 9.1b1 ""]
#not windows
lappend result [punk::lib::check::tclbug_console_deadspin_applies unix 9.0.2 ""]
#tcl 8.6 (different console driver - out of scope per G-039)
lappend result [punk::lib::check::tclbug_console_deadspin_applies windows 8.6.16 ""]
#fixed_in recorded: earlier runtimes affected, at/past runtimes clean
lappend result [punk::lib::check::tclbug_console_deadspin_applies windows 9.0.3 9.0.4]
lappend result [punk::lib::check::tclbug_console_deadspin_applies windows 9.0.4 9.0.4]
lappend result [punk::lib::check::tclbug_console_deadspin_applies windows 9.0.5 9.0.4]
lappend result [punk::lib::check::tclbug_console_deadspin_applies windows 9.1.0 9.0.4]
}\
-cleanup {
}\
-result [list 1 1 0 0 1 0 0 0]
test has_tclbug_console_deadspin_consistency {the live check matches the classifier applied to the live facts and gate variable}\
-setup $common -body {
set buginfo [punk::lib::check::has_tclbug_console_deadspin]
foreach key {bug bugref description level mitigated mitigation} {
lappend result [dict exists $buginfo $key]
}
lappend result [dict get $buginfo bugref]
lappend result [expr {
[dict get $buginfo bug] == [punk::lib::check::tclbug_console_deadspin_applies\
$::tcl_platform(platform) [info patchlevel] [set ::punk::lib::check::tclbug_console_deadspin_fixed_in]]
}]
#mitigated axis: boolean, only reportable on a triggered check, and mitigation
#text accompanies mitigated=1
lappend result [string is boolean -strict [dict get $buginfo mitigated]]
lappend result [expr {!([dict get $buginfo mitigated] && ![dict get $buginfo bug])}]
lappend result [expr {!([dict get $buginfo mitigated] && [dict get $buginfo mitigation] eq "")}]
}\
-cleanup {
}\
-result [list 1 1 1 1 1 1 f10d91c2d3 1 1 1 1]
test has_bugcheck_procs_return_standard_dict {every has_tclbug_*/has_libbug_* check returns a dict with a boolean bug key and a level}\
-setup $common -body {
set checkprocs [concat\
@ -69,6 +109,9 @@ namespace eval ::testspace {
if {[dict exists $buginfo level] && [dict get $buginfo level] ni {minor medium major}} {
lappend badprocs [list $bp badlevel [dict get $buginfo level]]
}
if {[dict exists $buginfo mitigated] && ![string is boolean -strict [dict get $buginfo mitigated]]} {
lappend badprocs [list $bp badmitigatedkey]
}
}
lappend result $badprocs
}\

231
src/tests/shell/testsuites/binscripts/dtplite.test

@ -0,0 +1,231 @@
package require tcltest
#Behaviour + artifact tests for bin/dtplite.cmd - the scriptwrap-multishell wrapped
#tcllib dtplite application (payload: src/scriptapps/dtplite.tcl + dtplite_wrap.toml).
#
#Context: 'dev doc.validate' (punk::mix::commandset::doc::validate) invokes a bare
#`dtplite validate <path>` which the punk repl unknown-handler resolves via auto_execok,
#so a dtplite executable must be findable on PATH. bin/dtplite.cmd provides that
#cross-platform (cmd.exe on windows dispatching to tclsh; sh/tclsh on unix).
#
#Covered:
# - artifact contract: bin/dtplite.cmd is LF-only and passes scriptwrap checkfile
# (no 512-byte label location errors)
# - source sync: re-wrapping the dtplite scriptset from src/scriptapps reproduces the
# committed bin/dtplite.cmd byte for byte (fix payloads and re-wrap - never the output)
# - execution usecases (windows via cmd.exe; unix via sh - both need a tclsh with the
# dtplite package or the project-vendored tcllib fallback):
# * no args -> usage error, nonzero exit
# * validate <good .man file> -> exit 0
# * validate <bad .man file> -> nonzero exit + doctools error on stderr
# * validate <directory tree> -> exit 0 (the 'dev doc.validate' usecase)
# * html generation to an output file
#
#NOTE for agents: bin/dtplite.cmd is GENERATED - edit src/scriptapps/dtplite.tcl /
#dtplite_wrap.toml and re-wrap with punk::mix::commandset::scriptwrap::multishell
#(see src/scriptapps/AGENTS.md).
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
variable testdir [file dirname [file normalize [info script]]]
#<projectroot>/src/tests/shell/testsuites/binscripts -> src/tests is 3 levels up
variable testsroot [file normalize [file join $testdir .. .. ..]]
variable projectroot [file normalize [file join $testsroot .. ..]]
variable target [file join $projectroot bin dtplite.cmd]
testConstraint havedtplitecmd [file exists $target]
testConstraint iswindows [expr {$::tcl_platform(platform) eq "windows"}]
testConstraint isunix [expr {$::tcl_platform(platform) eq "unix"}]
testConstraint havescriptwrap [expr {![catch {
package require Thread ;#punk::fileline textinfo (used by checkfile) calls thread::id
package require punk::mix::commandset::scriptwrap
}]}]
proc readbytes {path} {
set fd [open $path r]
fconfigure $fd -translation binary
set data [read $fd]
close $fd
return $data
}
#write with explicit lf translation
proc writefile_lf {path content} {
set fd [open $path w]
fconfigure $fd -translation lf
puts $fd $content
close $fd
}
#run bin/dtplite.cmd with args; returns dict {exit <code> output <stdout+stderr>}
#on windows via cmd.exe (the way the repl unknown-handler launches it), on unix via sh
proc run_dtplite {args} {
variable target
set output ""
set code 0
if {$::tcl_platform(platform) eq "windows"} {
set comspec [expr {[info exists ::env(ComSpec)] ? $::env(ComSpec) : "cmd.exe"}]
set cmdline [list $comspec /c [file nativename $target] {*}$args]
} else {
set cmdline [list sh $target {*}$args]
}
if {[catch {exec -- {*}$cmdline << "" 2>@1} output opts]} {
set ec [dict get $opts -errorcode]
if {[lindex $ec 0] eq "CHILDSTATUS"} {
set code [lindex $ec 2]
} else {
set code -1
}
}
return [dict create exit $code output $output]
}
#--- fixtures: minimal good/bad doctools manpages + a small doc tree -------------
variable good_man {[manpage_begin sample_good n 1.0]
[copyright {2026}]
[moddesc {Sample}]
[titledesc {A minimal valid doctools manpage}]
[description]
[para] This is a minimal valid doctools document used for smoke testing dtplite.
[section Usage]
[para] Nothing to see here.
[manpage_end]}
variable bad_man {[manpage_begin sample_bad n 1.0]
[copyright {2026}]
[moddesc {Sample}]
[titledesc {An invalid doctools manpage - manpage_end missing}]
[description]
[list_begin itemized]
[item] an item, but the list is never closed and manpage_end is missing}
variable fixdir [makeDirectory dtplite_fixture]
writefile_lf $fixdir/sample_good.man $good_man
writefile_lf $fixdir/sample_bad.man $bad_man
variable treedir [makeDirectory dtplite_fixture_tree]
file mkdir $treedir/sub
writefile_lf $treedir/one.man [string map {sample_good tree_one} $good_man]
writefile_lf $treedir/sub/two.man [string map {sample_good tree_two} $good_man]
#--- artifact contract ------------------------------------------------------------
test dtplite_cmd_lf_only {the committed bin/dtplite.cmd uses LF line endings exclusively (cmd label scanning depends on it)}\
-constraints havedtplitecmd\
-setup $common -body {
variable target
set data [readbytes $target]
lappend result [expr {[string first \r $data] == -1}]
}\
-cleanup {
}\
-result [list 1]
test dtplite_cmd_checkfile_no_label_errors {the committed bin/dtplite.cmd passes scriptwrap checkfile - no 512-byte label location errors}\
-constraints {havedtplitecmd havescriptwrap}\
-setup $common -body {
variable target
set summary [punk::mix::commandset::scriptwrap::checkfile $target]
lappend result [string match "*ERROR: label location errors*" $summary]
#sanity: the analysis actually ran (labels enumerated)
lappend result [string match "*call-labels-found:*" $summary]
}\
-cleanup {
}\
-result [list 0 1]
test dtplite_cmd_roundtrip_no_drift {re-wrapping the dtplite scriptset from src/scriptapps reproduces the committed bin/dtplite.cmd byte for byte}\
-constraints {havedtplitecmd havescriptwrap}\
-setup $common -body {
variable projectroot
variable target
set scriptapps [file join $projectroot src scriptapps]
if {![file exists $scriptapps/dtplite_wrap.toml]} {
lappend result no_dtplite_sources
} else {
set outdir [makeDirectory dtplite_roundtrip]
set startdir [pwd]
cd $scriptapps
set res [punk::mix::commandset::scriptwrap::multishell dtplite -outputfolder $outdir -askme 0 -force 1]
cd $startdir
set fresh [dict get $res filename]
lappend result [expr {[readbytes $fresh] eq [readbytes $target]}]
#a failure here means either bin/dtplite.cmd was edited directly (fix
#src/scriptapps/dtplite.tcl or dtplite_wrap.toml instead and re-wrap),
#or the payload/template changed without regenerating bin/dtplite.cmd
}
}\
-cleanup {
}\
-result [list 1]
#--- execution usecases -----------------------------------------------------------
test dtplite_exec_no_args_usage_error {running dtplite.cmd without arguments exits nonzero with the dtplite usage message}\
-constraints havedtplitecmd\
-setup $common -body {
set r [run_dtplite]
lappend result [expr {[dict get $r exit] != 0}]
lappend result [string match "*wrong#args*" [dict get $r output]]
}\
-cleanup {
}\
-result [list 1 1]
test dtplite_exec_validate_good {validate of a well-formed .man file exits 0}\
-constraints havedtplitecmd\
-setup $common -body {
variable fixdir
set r [run_dtplite validate [file nativename $fixdir/sample_good.man]]
lappend result [dict get $r exit]
}\
-cleanup {
}\
-result [list 0]
test dtplite_exec_validate_bad {validate of a malformed .man file exits nonzero and reports a doctools format error}\
-constraints havedtplitecmd\
-setup $common -body {
variable fixdir
set r [run_dtplite validate [file nativename $fixdir/sample_bad.man]]
lappend result [expr {[dict get $r exit] != 0}]
lappend result [string match "*FmtError*" [dict get $r output]]
}\
-cleanup {
}\
-result [list 1 1]
test dtplite_exec_validate_tree {validate of a directory tree of .man files exits 0 - the 'dev doc.validate' usecase}\
-constraints havedtplitecmd\
-setup $common -body {
variable treedir
set r [run_dtplite validate [file nativename $treedir]]
lappend result [dict get $r exit]
}\
-cleanup {
}\
-result [list 0]
test dtplite_exec_html_generation {html generation from a .man file produces an html output file mentioning the manpage name}\
-constraints havedtplitecmd\
-setup $common -body {
variable fixdir
set outfile $fixdir/sample_good.html
set r [run_dtplite -o [file nativename $outfile] html [file nativename $fixdir/sample_good.man]]
lappend result [dict get $r exit]
lappend result [file exists $outfile]
if {[file exists $outfile]} {
set html [readbytes $outfile]
lappend result [string match "*sample_good*" $html]
} else {
lappend result no_output_file
}
}\
-cleanup {
file delete -force $fixdir/sample_good.html
}\
-result [list 0 1 1]
}
tcltest::cleanupTests ;#needed to produce test summary line.

47
src/vfs/_vfscommon.vfs/modules/punk-0.2.3.tm → src/vfs/_vfscommon.vfs/modules/punk-0.2.6.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
@ -8604,7 +8616,11 @@ namespace eval punk {
warnings for known Tcl bugs affecting this interpreter
and known bugs in bundled library packages
(as detected by the punk::lib::check::has_tclbug_*
and has_libbug_* checks)."
and has_libbug_* checks).
A warning whose buginfo reports a shipped punkshell
mitigation keeps its severity level but is annotated
'(mitigated)' and rendered subdued (grey), with the
mitigation described."
@values -min 0 -max 0
}
proc tcl {context args} {
@ -8627,17 +8643,36 @@ namespace eval punk {
if {[dict exists $buginfo level]} {
set level [dict get $buginfo level]
}
#mitigated is an axis orthogonal to level: the defect keeps its severity
#classification but a shipped punkshell mitigation covers it, so the
#warning renders subdued (grey) with a '(mitigated)' annotation and any
#mitigation text from the buginfo dict.
set mitigated 0
if {[dict exists $buginfo mitigated]} {
set mitigated [dict get $buginfo mitigated]
}
if {$mitigated} {
set highlight [punk::ansi::a+ term-grey]
} else {
switch -- $level {
minor {set highlight [punk::ansi::a+ cyan]}
medium {set highlight [punk::ansi::a+ yellow]}
major {set highlight [punk::ansi::a+ red bold]}
default {set highlight ""}
}
}
set levelshown $level
if {$mitigated} {
append levelshown " (mitigated)"
}
set indent " "
append warningblock \n $highlight "warning level: $level $bp triggered."
append warningblock \n $highlight "warning level: $levelshown $bp triggered."
if {[dict exists $buginfo description]} {
append warningblock \n "[punk::lib::indent [dict get $buginfo description] $indent]"
}
if {[dict exists $buginfo mitigation] && [dict get $buginfo mitigation] ne ""} {
append warningblock \n "[punk::lib::indent "mitigated: [dict get $buginfo mitigation]" $indent]"
}
if {[dict exists $buginfo url] && [dict get $buginfo url] ne ""} {
#full reference url (e.g. non tcl-core trackers such as tcludp)
append warningblock \n "${indent}see [punk::ansi::hyperlink [dict get $buginfo url]]"
@ -9403,7 +9438,7 @@ punkcheck::cli set_alias punkcheck
package provide punk [namespace eval punk {
#FUNCTL
variable version
set version 0.2.3
set version 0.2.6
}]

1337
src/vfs/_vfscommon.vfs/modules/punk/args-0.6.0.tm → src/vfs/_vfscommon.vfs/modules/punk/args-0.12.0.tm

File diff suppressed because it is too large Load Diff

62
src/vfs/_vfscommon.vfs/modules/punk/args/moduledoc/tclcore-0.2.0.tm → src/vfs/_vfscommon.vfs/modules/punk/args/moduledoc/tclcore-0.3.4.tm

@ -8,7 +8,7 @@
# (C) 2025
#
# @@ Meta Begin
# Application punk::args::moduledoc::tclcore 0.2.0
# Application punk::args::moduledoc::tclcore 0.3.4
# Meta platform tcl
# Meta license MIT
# @@ Meta End
@ -18,7 +18,7 @@
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::args::moduledoc::tclcore 0 0.2.0]
#[manpage_begin punkshell_module_punk::args::moduledoc::tclcore 0 0.3.4]
#[copyright "2025"]
#[titledesc {punk::args definitions for tcl core commands}] [comment {-- Name section and table of contents description --}]
#[moddesc {tcl core argument definitions}] [comment {-- Description at end of page heading --}]
@ -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
@ -4566,11 +4591,23 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
#@form -form {cancelid} -synopsis "after cancel id"
@form -form {cancelid}
#The cancelid/cancelscript overlap is DOCUMENTED behaviour (an id-shaped word
#is genuinely ambiguous - see the id -type comment below), so it is sanctioned
#for punk::args::formcheck reporting (G-074). Parse behaviour is unaffected:
#'after cancel <id-shaped-word>' still raises multipleformmatches.
@form -form {cancelid} -overlapallowed {cancelscript}
@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 +4630,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 +4891,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 +6505,6 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
# -- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- --- ---
lappend PUNKARGS [list {
@dynamic
@id -id ::join
@cmd -name "Built-in: join"\
-summary\
@ -9299,7 +9340,6 @@ tcl::namespace::eval punk::args::moduledoc::tclcore {
}]}
}
punk::args::define {
@dynamic
@id -id ::split
@cmd -name "Built-in: split"\
-summary\
@ -12543,7 +12583,7 @@ namespace eval ::punk::args::register {
package provide punk::args::moduledoc::tclcore [tcl::namespace::eval punk::args::moduledoc::tclcore {
variable pkg punk::args::moduledoc::tclcore
variable version
set version 0.2.0
set version 0.3.4
}]
return

75
src/vfs/_vfscommon.vfs/modules/punk/lib-0.4.0.tm → src/vfs/_vfscommon.vfs/modules/punk/lib-0.4.3.tm

@ -8,7 +8,7 @@
# (C) 2024
#
# @@ Meta Begin
# Application punk::lib 0.4.0
# Application punk::lib 0.4.3
# Meta platform tcl
# Meta license BSD
# @@ Meta End
@ -18,7 +18,7 @@
# doctools header
# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++
#*** !doctools
#[manpage_begin punkshell_module_punk::lib 0 0.4.0]
#[manpage_begin punkshell_module_punk::lib 0 0.4.3]
#[copyright "2024"]
#[titledesc {punk general utility functions}] [comment {-- Name section and table of contents description --}]
#[moddesc {punk library}] [comment {-- Description at end of page heading --}]
@ -250,6 +250,63 @@ tcl::namespace::eval punk::lib::check {
return [dict create bug $bug bugref e38dc74e2 description $description level medium]
}
#G-076: version gate for the tcl9 dead-console defect (upstream ticket f10d91c2d3, root-caused
#in G-039). Shared by the 'help tcl' warning and the repl dead-console watchdog arming (both
#consult has_tclbug_console_deadspin). Empty = no released Tcl is known to contain the upstream
#fix, so every Tcl 9 windows runtime classifies as affected. Set this only after the G-039 kill
#procedure, re-run on the fixed released runtime with the watchdog disabled, shows a clean
#script-visible eof exit (see goals/G-076-tcl9-deadconsole-fix-adoption.md).
variable tclbug_console_deadspin_fixed_in ""
#pure classifier, separated for testability - facts in, verdict out
proc tclbug_console_deadspin_applies {platform tclversion fixed_in} {
if {$platform ne "windows"} {
return 0
}
if {![package vsatisfies $tclversion 9]} {
#tcl 8.6 has a different console driver - out of scope per G-039 (user decision 2026-07-12)
return 0
}
if {$fixed_in eq ""} {
#no released fix known - all Tcl 9 windows runtimes affected
return 1
}
return [expr {[package vcompare $tclversion $fixed_in] < 0}]
}
proc has_tclbug_console_deadspin {} {
#Tcl 9 windows console driver defect pair (win/tclWinConsole.c): when the hosting
#console dies (killed conhost/terminal), (a) ConsoleEventProc drops the error/EOF
#notification so a stdin readable fileevent never fires - the script is blind to the
#dead console; (b) ConsoleReaderThread busy-loops on the persistent error, spinning
#~2 cores until the channel is closed. Root-caused 2026-07-12 - see archived goal
#G-039; upstream ticket f10d91c2d3 filed 2026-07-12. punk::repl >= 0.5.0 mitigates
#with a console liveness watchdog (repl::console_watchdog) gated on this same check.
#Version-based detection only - a behavioural probe would require killing a console.
#The buginfo dict carries the mitigated/mitigation axis: level stays major (the core
#defect's severity), mitigated reports whether punk::repl >= 0.5.0 (console liveness
#watchdog) is available to this runtime - 'help tcl' renders mitigated warnings subdued.
variable tclbug_console_deadspin_fixed_in
set bug [tclbug_console_deadspin_applies $::tcl_platform(platform) [info patchlevel] $tclbug_console_deadspin_fixed_in]
set description "Tcl 9 windows console driver: a dead console (killed conhost/terminal) is never\ndelivered to the script as a fileevent, and the core's console reader thread busy-loops\non the persistent error - an orphaned tclsh spins CPU indefinitely. Plain tclsh scripts\nreading a console stdin have no script-level escape (see goal G-076)."
set replversion [package provide punk::repl]
if {$replversion eq ""} {
#not loaded - determine what version would be provided, without loading it:
#an unsatisfiable require triggers the package unknown scan (registering ifneeded
#scripts) then fails before any load (0.4.3 dev modules are alpha - below 999999).
catch {package require punk::repl 999999}
set available [package versions punk::repl]
if {[llength $available]} {
set replversion [lindex [lsort -command {package vcompare} $available] end]
}
}
set mitigated [expr {$bug && $replversion ne "" && [package vsatisfies $replversion 0.5-]}]
set mitigation ""
if {$mitigated} {
set mitigation "punk::repl $replversion is available to this runtime: its console liveness watchdog\n(armed when the repl serves the process-default console) closes the dead channel and\nexits cleanly instead of spinning. Non-repl console reads remain exposed."
}
return [dict create bug $bug bugref f10d91c2d3 description $description level major mitigated $mitigated mitigation $mitigation]
}
#has_libbug_* procs report bugs in bundled/vendored library packages rather than the Tcl core.
#They are surfaced through the same 'help tcl' warning report as the has_tclbug_* checks.
@ -2455,8 +2512,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 +2774,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
}
@ -9173,7 +9238,7 @@ namespace eval ::punk::args::register {
package provide punk::lib [tcl::namespace::eval punk::lib {
variable pkg punk::lib
variable version
set version 0.4.0
set version 0.4.3
}]
return

162
src/vfs/_vfscommon.vfs/modules/punk/ns-0.2.0.tm → src/vfs/_vfscommon.vfs/modules/punk/ns-0.5.0.tm

@ -7,7 +7,7 @@
# (C) 2023
#
# @@ Meta Begin
# Application punk::ns 0.2.0
# Application punk::ns 0.5.0
# Meta platform tcl
# Meta license <unspecified>
# @@ Meta End
@ -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]
}
@ -7657,6 +7761,6 @@ namespace eval ::punk::args::register {
## Ready
package provide punk::ns [tcl::namespace::eval punk::ns {
variable version
set version 0.2.0
set version 0.5.0
}]
return

15
src/vfs/_vfscommon.vfs/modules/punk/repl-0.5.0.tm → src/vfs/_vfscommon.vfs/modules/punk/repl-0.5.1.tm

@ -634,12 +634,16 @@ proc repl::start {args} {
#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.
#console; piped/foreign/8.6 inputs are unaffected. The version gate (G-076) is
#punk::lib::check::has_tclbug_console_deadspin - shared with the 'help tcl' warning -
#so runtimes at or past a verified fixed Tcl release (check::tclbug_console_deadspin_fixed_in)
#don't arm the probe.
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)]} {
&& ![info exists console_watchdog_afterids($inchan)]
&& [dict get [punk::lib::check::has_tclbug_console_deadspin] bug]} {
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]]
@ -983,7 +987,10 @@ namespace eval repl::argdoc {
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
windows, and only while punk::lib::check::has_tclbug_console_deadspin
reports the runtime affected (G-076 shared version gate - a Tcl release
containing the verified upstream fix for ticket f10d91c2d3 arms nothing).
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
@ -4518,7 +4525,7 @@ namespace eval ::punk::args::register {
package provide punk::repl [namespace eval punk::repl {
variable version
set version 0.5.0
set version 0.5.1
}]
#repl::start $program_read_stdin_pipe
Loading…
Cancel
Save