From f62e3efad9a8b4f31069df3b6864a6d34b88b161 Mon Sep 17 00:00:00 2001 From: macblackstuff <148771651+macblackstuff@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:50:00 +0200 Subject: [PATCH] docs: portability, professional pass and Windows support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script now reconfigures stdout to UTF-8 in main(), so the report is byte-identical on Windows legacy console codepages (cp437/cp1252) instead of crashing with UnicodeEncodeError mid-report (the em-dash in the --source span message). Regression test fails without the fix; a CI portability job runs the full self-check with and without -O on windows-latest and macos-latest. SKILL.md: description cut 640 -> 483 characters (released OpenAI Codex builds hard-fail above 500); script paths anchored to the directory containing SKILL.md so symlinked installs work; py -3 documented; the --source paragraph and the rules-audit sentence split into readable lists; the dangling "P7's I14 and I15" reference replaced with a concrete example; work package glossed once. README: example ships as committed files (examples/example.md and the byte-reproducible example-output.md); the finding classes counted as four plus the unstated residue; N-squared/DSM glossed; Windows noted; test counts updated to 84. RUNBOOK defers section-9 detail to SKILL.md. SECURITY names the supported version without first-release trivia; the bug-report version placeholder is no longer a hardcoded tag. Changelog records everything under Unreleased and dates 0.2.1 correctly as 2026-09-26. Validated: 84 self-tests green with and without -O; skills-ref validate (clean pinned 69ef37e9); every http(s) link in tracked .md files returns 200; every relative link resolves; example output reproduced byte-for-byte from examples/example.md. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/tests.yml | 21 ++++++ CHANGELOG.md | 25 ++++++- README.md | 39 +++++++--- SECURITY.md | 4 +- examples/example-output.md | 75 +++++++++++++++++++ examples/example.md | 23 ++++++ skills/interface-matrix/SKILL.md | 59 +++++++++------ skills/interface-matrix/references/RUNBOOK.md | 4 +- .../scripts/interface_matrix.py | 8 ++ .../scripts/test_interface_matrix.py | 34 +++++++++ 11 files changed, 252 insertions(+), 42 deletions(-) create mode 100644 examples/example-output.md create mode 100644 examples/example.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 876eb8e..0290dc6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -11,7 +11,7 @@ body: attributes: label: Version description: Release tag or commit you ran. - placeholder: v0.2.0 + placeholder: latest release tag or commit sha validations: required: true - type: dropdown diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2147d3..071d396 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,6 +54,27 @@ jobs: - name: skills CLI install smoke test (all agents) run: .github/scripts/smoke-install.sh + portability: + # The skill must work where its users are: Windows consoles use legacy + # codepages (cp437/cp1252), macOS ships Python 3 via xcode/clang tooling. + # `python3` is on PATH on both runners; no shell:true needed since the + # command is a single invocation. + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.x" + - name: interface-matrix self-check (${{ matrix.os }}) + working-directory: skills/interface-matrix + run: | + python3 scripts/test_interface_matrix.py + python3 -O scripts/test_interface_matrix.py + commit-emails: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 493168f..104d2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## Unreleased +- Windows is a supported platform: the script reconfigures stdout to UTF-8, so the report + is byte-identical on legacy Windows console codepages (cp437/cp1252) instead of crashing + with `UnicodeEncodeError` mid-report. Regression-tested; a CI `portability` job runs the + full self-check on `windows-latest` and `macos-latest` in addition to Linux. +- SKILL.md: description cut to 483 characters โ€” released OpenAI Codex builds reject + descriptions over 500; paths now say "run scripts from the directory containing this + SKILL.md" so a symlinked install works; `py -3` documented for Windows; the ยง2 + `--source` paragraph and the ยง1 rules-audit sentence broken into readable form; the + unexplained "(P7's I14 and I15)" replaced with a concrete example. +- README: worked example now ships as committed files (`examples/example.md`, + `examples/example-output.md`, byte-reproducible); the four finding classes counted + correctly; Nยฒ/DSM glossed in plain English; Windows commands documented; tests count 84. +- RUNBOOK: section-9 detail now points at SKILL.md ยง1 instead of restating it; the + `--source` citation rule split into three sentences. +- Security policy "Supported version" now names the supported version (the latest + release) without stale first-release trivia. +- Issue-form version placeholder is no longer a hardcoded release tag. - CI installs and self-tests the skill for every agent the `skills` CLI supports, with the agent list read from the CLI at run time. - Copyright holder in `LICENSE` and the README is now `macblackstuff`. @@ -14,7 +31,13 @@ - The skill description carries an explicit Not-for clause. - CI workflow vendored into the repository. -## 0.2.1 โ€” 2026-09-27 +## 0.2.2 โ€” 2026-09-28 + +- Shipped the conduct and licence changes below: `CODE_OF_CONDUCT.md` names + `conduct@macblackstuff.com`, a live mailbox, so the published policy needed a release. +- Nothing else changed in this release; v0.2.1 remains the functional baseline. + +## 0.2.1 โ€” 2026-09-26 - Moved to [github.com/macblackstuff/interface-matrix](https://github.com/macblackstuff/interface-matrix); every link and install command now uses the new owner. The old `macblackstuff-labs` URLs redirect. diff --git a/README.md b/README.md index 75cba77..9155fc9 100644 --- a/README.md +++ b/README.md @@ -12,17 +12,20 @@ you or by an agent from a transcript, spec or code read. Each interface row name producer, a consumer and four attributes: flows, format, trigger, owner. The script builds the directed graph of the stated interfaces, partitions it (strongly -connected components, then a topological order of the condensation) and reports what the -list does not say out loud: interface rows with missing attributes, rows whose producer or -consumer is still `?`, internal components nothing feeds, outputs nothing consumes, -isolated components, feedback loops, and every ordered component pair nobody has stated -either way. With `--source FILE` it also reports the lines of the source document that no -cell cites. +connected components, then a topological order of the condensation) and reports four +classes of finding โ€” missing components, interface gaps, boundary problems (unconsumed +outputs and isolated components) and feedback loops โ€” plus every component pair nobody +has stated either way. With `--source FILE` it also reports the lines of the source +document that no cell cites. The output is one Markdown report on stdout: numbered sections plus the matrix itself, row feeds column. Nothing is inferred and no gap is filled in for you โ€” a gap stays a gap until a human resolves it. +An Nยฒ matrix (N-squared; a design structure matrix, or DSM) lists every component on both +axes, so each of the Nร—N cells is a yes/no/unknown about one directed pair โ€” that is what +a flat interface list cannot show. + ## Who it is for Systems engineers, architects and technical leads planning or auditing a system of roughly @@ -33,7 +36,10 @@ and it works on its own. ## Example -From `skills/interface-matrix`, write this to `example.md`: +A ready-made input lives at [`examples/example.md`](examples/example.md), and the real +report it produces is committed beside it as +[`examples/example-output.md`](examples/example-output.md). To follow along, from +`skills/interface-matrix` write this to `example.md`: ```markdown ## Components @@ -79,13 +85,13 @@ Eight sections come back. The summary, the two finding tables and the matrix: | line | producer | consumer | flows | |---|---|---|---| -| line 16 | ? | Analyst | weekly digest | +| line 23 | ? | Analyst | weekly digest | ## 3. Interface gaps | line | producer | consumer | missing | |---|---|---|---| -| line 15 | Store | Scorer | Format, Trigger | +| line 22 | Store | Scorer | Format, Trigger | ``` ``` @@ -120,18 +126,25 @@ component pairs nobody has ruled in or out. Verify the install from inside the installed folder with [`scripts/test_interface_matrix.py`](skills/interface-matrix/scripts/test_interface_matrix.py): ```bash -python3 scripts/test_interface_matrix.py # Ran 83 tests ... OK +python3 scripts/test_interface_matrix.py # Ran 84 tests ... OK ``` +On Windows the interpreter is `py -3` (`py -3 scripts/interface_matrix.py example.md`); +the script writes UTF-8 whatever the console codepage is, so the report is identical on +every platform. + ### Harnesses tested -CI installs the skill with the [`skills` CLI`](https://github.com/vercel-labs/skills) on every push -and pull request, once per agent in its own throwaway home, and runs the 83 tests from each +CI installs the skill with the [`skills` CLI](https://github.com/vercel-labs/skills) on every push +and pull request, once per agent in its own throwaway home, and runs the 84 tests from each installed copy. Every agent the CLI supports is covered โ€” 79 at the time of writing (`skills` 1.7.0), of which 77 are installed and tested; the list is read from the CLI at run time. Two agents are excluded with reasons recorded in `.github/scripts/smoke-install.sh`: `eve` and `promptscript` โ€” the CLI reports that neither supports global skill installation. +A separate CI job runs the same tests on Windows and macOS, so the skill is verified on the +platforms its users actually run, not just Linux. + The skill itself is harness-neutral: it is a `SKILL.md` plus standard-library Python, with no agent-specific commands. @@ -149,6 +162,8 @@ python3 scripts/interface_matrix.py INPUT.md --sample 0 python3 scripts/interface_matrix.py INPUT.md --source TRANSCRIPT.txt ``` +On Windows use `py -3` in place of `python3`. + `--sample N` sets how many unstated pairs are printed (default 20, `0` = all). `--source FILE` adds the coverage section over the document the inventory was read from. diff --git a/SECURITY.md b/SECURITY.md index fe20966..abf17b6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,8 +2,8 @@ ## Supported version -Only the latest release is supported. The first release is v0.1.0. Fixes are published as a new -release; older tags do not receive backports. +Only the latest release is supported. Fixes are published as a new release; older tags do +not receive backports. ## Reporting a vulnerability diff --git a/examples/example-output.md b/examples/example-output.md new file mode 100644 index 0000000..1979770 --- /dev/null +++ b/examples/example-output.md @@ -0,0 +1,75 @@ +# Interface matrix report + +## 1. Summary + +- components: 4 (3 internal, 1 external) +- specified interfaces: 1 +- interfaces with gaps: 1 +- explicit none: 0 +- missing-component candidates: 1 +- unstated pairs: 10 +- feedback loops: 0 +- self-dependencies: 0 +- superseded rows: 0 (interfaces 0, components 0) + +## 2. Missing-component candidates + +| line | producer | consumer | flows | +|---|---|---|---| +| line 23 | ? | Analyst | weekly digest | + +## 3. Interface gaps + +| line | producer | consumer | missing | +|---|---|---|---| +| line 22 | Store | Scorer | Format, Trigger | + +## 4. Boundary check + +External components are exempt. + +- nothing feeds (internal): Ingest +- output nothing consumes (internal): Scorer +- isolated (internal): none + +## 5. Feedback loops + +No feedback loops. + +Self-dependencies: none + +## 6. Partitioned order + +1. Ingest +2. Analyst +3. Store +4. Scorer + +## 7. Unstated pairs + +showing 10 of 10 (neither an interface nor `none`; external-to-external excluded) + +- Ingest -> Scorer +- Ingest -> Analyst +- Store -> Ingest +- Store -> Analyst +- Scorer -> Ingest +- Scorer -> Store +- Scorer -> Analyst +- Analyst -> Ingest +- Analyst -> Store +- Analyst -> Scorer + +## 8. Matrix + +Row feeds column. Legend: `X` specified, `g` gap, `-` none, blank unstated, `S` self. + +``` + 1 2 3 4 + 1 Ingest . X + 2 Analyst . + 3 Store . g + 4 Scorer . +``` + +below-diagonal check: every below-diagonal mark lies inside a loop block. diff --git a/examples/example.md b/examples/example.md new file mode 100644 index 0000000..db55b9f --- /dev/null +++ b/examples/example.md @@ -0,0 +1,23 @@ +# Example inventory for interface-matrix + +The input from the README's Example section, as a real file. Run from +`skills/interface-matrix`: + + python3 scripts/interface_matrix.py ../../examples/example.md + +## Components + +| Component | Kind | Notes | +|---|---|---| +| Ingest | | pulls raw events | +| Store | | event store | +| Scorer | | scores events | +| Analyst | external | reads the digest | + +## Interfaces + +| Producer | Consumer | Flows | Format | Trigger | Owner | Source | Status | +|---|---|---|---|---|---|---|---| +| Ingest | Store | raw event rows | ndjson file | nightly cron | platform | S:L42 | | +| Store | Scorer | event batches | ? | ? | platform | S:L44 | | +| ? | Analyst | weekly digest | ? | ? | ? | | | diff --git a/skills/interface-matrix/SKILL.md b/skills/interface-matrix/SKILL.md index e56cde5..cf321d5 100644 --- a/skills/interface-matrix/SKILL.md +++ b/skills/interface-matrix/SKILL.md @@ -1,8 +1,8 @@ --- name: interface-matrix -description: "Builds an N-squared interface matrix (DSM) from a Markdown component and interface inventory: finds missing components, interface gaps, unconsumed outputs, feedback loops and never-stated component pairs. Use when planning or auditing a system of roughly eight or more components, once the components are known and before work packages are cut, or on any request for an N2 diagram, design structure matrix, interface list, or gap analysis between components. Not for dependency graphs of source files, for drawing diagrams, or for discovering components on its own: it needs a hand-written Components and Interfaces inventory in Markdown." +description: "Builds an N-squared interface matrix (DSM) from a Markdown component and interface inventory: finds missing components, interface gaps, unconsumed outputs, feedback loops and never-stated component pairs. Use when planning or auditing a system of eight or more components, before work packages are cut, or on any N2 diagram, design structure matrix or gap-analysis request. Not for source-code dependency graphs, drawing, or discovering components: it needs a hand-written inventory." license: MIT -compatibility: Requires Python 3.9 or newer. Standard library only โ€” no dependencies and no install step. +compatibility: "Requires Python 3.9 or newer (Windows: py -3). Standard library only โ€” no dependencies and no install step." metadata: author: macblackstuff version: 0.2.1 @@ -18,7 +18,8 @@ Pass 3 of 7 in the systems-engineering decomposition pipeline. purpose, declared inputs/outputs and owner. - **Output:** the interface list plus four finding classes. Gaps feed the SOURCE/RESEARCH/USER/DEFAULT gap register; the partitioned order and the loop - blocks feed the work packages and their sequencing. + blocks feed the work packages (the units of planned work the packaging pass cuts + from this output) and their sequencing. Do not run this before the components are known, and do not cut work packages before it has run โ€” an interface discovered after packaging re-opens the packaging. @@ -79,14 +80,17 @@ Rules: (`*` = any classed component): not listed, not sampled. A component with a blank `Class` matches no rule, not even `*`, so a forgotten Class cell can never drop pairs from review โ€” section 9 names every unclassed component. `review` wins where both match. An - explicit interface or `none` row always beats a rule. Section 9 reports, per rule, the - pairs it settled `none` and the pairs it matched at all (a pair any `review` rule - reclaimed is matched but not settled; a pair settled by several `none` - rules is counted under each, so the settled column sums to at least the total), dead rules, `none` rules that match an explicit interface, the unclassed - components, and the residue left for pair-by-pair review. An unknown `Disposition`, or a - class no component has, exits 1. A table is read as the Rules table only if its header - names both `Producer class` and `Consumer class`, so a foreign `| Disposition | Reason |` - table is left alone. + explicit interface or `none` row always beats a rule. +- Section 9 (rules audit, printed only when a Rules table is present) reports, per rule: + - the pairs it settled `none`; + - the pairs it matched at all โ€” a pair any `review` rule reclaimed is matched but not + settled, and a pair settled by several `none` rules is counted under each, so the + settled column sums to at least the total; + - dead rules, and `none` rules that match an explicit interface; + - the unclassed components, and the residue left for pair-by-pair review. +- An unknown `Disposition`, or a class no component has, exits 1. A table is read as the + Rules table only if its header names both `Producer class` and `Consumer class`, so a + foreign `| Disposition | Reason |` table is left alone. - `Source` and `Status` are optional; the other six interface columns are required. A `Status` starting `superseded` retires the row (see ยง5). - One Components table and one Interfaces table per file. A second table of either @@ -98,8 +102,9 @@ Rules: ## 2. Run it -Run from this skill's own directory; every `scripts/...` path below is relative to it. -Python 3.9 or newer, standard library only. +Run scripts from the directory containing this SKILL.md โ€” every `scripts/...` path below +is relative to it, wherever the skill is installed and whatever the working directory is. +Python 3.9 or newer, standard library only (`python3`; on Windows `py -3`). ```bash python3 scripts/interface_matrix.py INPUT.md > OUTPUT.md @@ -110,16 +115,20 @@ python3 scripts/interface_matrix.py INPUT.md --sample 0 python3 scripts/interface_matrix.py INPUT.md --source TRANSCRIPT.txt ``` -`--source FILE` checks coverage of the file the inventory was read from: every `L`, -`L-` and `L7,11-12` in any cell of any active Components or Interfaces row counts -as cited โ€” a Rules row's `Reason` justifies the rule and models nothing, and a superseded -row models nothing any more, so their citations do not cover a line, though they are still -range-checked โ€” and section 10 -lists the uncited lines as contiguous spans (blank lines ignored) with counts. Those spans -are where an unmodelled component or interface hides. A citation past the file's last line exits 1, as -does `L0` (source line numbers start at 1); both need `--source` to be caught. A reversed -range such as `L9-7` exits 1 while parsing, with or without `--source`. Every one of these -errors names the input line of the row that carries the citation. +`--source FILE` checks coverage of the file the inventory was read from: + +- Every `L`, `L-` and `L7,11-12` in any cell of any **active** Components or + Interfaces row counts as cited. +- A Rules row's `Reason` justifies the rule and models nothing, and a superseded row + models nothing any more, so their citations do not cover a line โ€” though they are + still range-checked. +- Section 10 lists the uncited lines as contiguous spans (blank lines ignored) with + counts. Those spans are where an unmodelled component or interface hides. + +Errors, each naming the input line of the row that carries the citation: a citation past +the file's last line exits 1, as does `L0` (source line numbers start at 1); both need +`--source` to be caught. A reversed range such as `L9-7` exits 1 while parsing, with or +without `--source`. `--sample N` sets how many unstated pairs are printed (default 20, `0` = all). The sample is drawn deterministically: round-robin across the producer rows that have @@ -146,7 +155,9 @@ So review, cell by cell: 1. Every listed row: are the four attributes right, and does the source support them? Then, per row: can the named producer actually produce this flow, and can the named consumer actually use it? If not, replace that endpoint with `?` and rerun โ€” a named - but incapable endpoint is how a missing component hides (P7's I14 and I15). + but incapable endpoint is how a missing component hides (for example, a component + "Ingest" named as consumer of an agent's reply: it cannot hold that conversation, so + the real endpoint is a component nobody declared yet). 2. The rules (section 9): is each `none` rule true of every pair it matched? A dead rule is wrong or premature; a rule that also matches an explicit interface contradicts it. 3. The residue (section 7): for each pair, is "no interface" actually true? Raise diff --git a/skills/interface-matrix/references/RUNBOOK.md b/skills/interface-matrix/references/RUNBOOK.md index 08deed2..a6e1b3c 100644 --- a/skills/interface-matrix/references/RUNBOOK.md +++ b/skills/interface-matrix/references/RUNBOOK.md @@ -35,13 +35,13 @@ Expected: only `argparse`, `difflib`, `graphlib`, `re`, `sys`. Any third-party i ``` `--sample N` prints N; `--sample 0` prints all. A sample is drawn deterministically: round-robin across the producer rows that have unstated pairs, spread evenly along each row so the picks sweep across columns rather than exhausting the first row. On a 50-component system that is ~2,500 lines, which is the point: the sparse form hides false negatives. -3. **Settle unstated pairs at class level.** Give components a `Class` cell and add a Rules table (`Producer class | Consumer class | Disposition | Reason`, disposition `none` or `review`, `*` = any classed component; a component with a blank `Class` matches no rule, and section 9 names every unclassed component). A `none` rule takes every pair of those classes out of section 7; `review` wins where both match; an explicit interface or `none` row always beats a rule. Section 9 then reports, per rule, the pairs it settled `none` and the pairs it matched at all (a pair any `review` rule reclaimed is matched but not settled; a pair settled by several `none` rules is counted under each), dead rules, `none` rules that contradict an explicit interface, and the unclassed components, and the residue still needing pair-by-pair review. Review the rules as carefully as the pairs they replace โ€” one wrong rule silences hundreds of pairs. +3. **Settle unstated pairs at class level.** Give components a `Class` cell and add a Rules table (`Producer class | Consumer class | Disposition | Reason`, disposition `none` or `review`, `*` = any classed component; a component with a blank `Class` matches no rule, and section 9 names every unclassed component). A `none` rule takes every pair of those classes out of section 7; `review` wins where both match; an explicit interface or `none` row always beats a rule. What section 9 then reports per rule โ€” settled pairs, matched pairs, dead rules, contradictions, the residue โ€” is defined in SKILL.md ยง1; read it there before trusting the audit. Review the rules as carefully as the pairs they replace โ€” one wrong rule silences hundreds of pairs. 4. **Check what the source says and the inventory does not.** ```bash python3 scripts/interface_matrix.py INPUT.md --source TRANSCRIPT.txt ``` - Every `L`, `L-` and `L7,11-12` in any cell of any active Components or Interfaces row counts as a citation; a Rules row's `Reason` justifies the rule and models nothing, and a superseded row models nothing any more, so their citations cover no source line, but every one of them is still range-checked; section 10 lists the source lines nothing cites as contiguous spans (blank lines ignored) with the first 80 characters of each span, plus the line/cited/uncited counts. Read every span: that is where an unmodelled component or interface hides. A reversed range such as `L9-7` exits 1 while parsing, with or without `--source`; a citation past the file's last line and `L0` are only detectable against a source file, so they exit 1 only under `--source`. + A citation is any `L`, `L-` or `L7,11-12` in a cell of an active Components or Interfaces row. Not citations, but still range-checked: a Rules row's `Reason`, and any citation on a superseded row. Section 10 prints the source lines nothing cites as contiguous spans (blank lines ignored), the first 80 characters of each span, and the line/cited/uncited counts. Read every span: that is where an unmodelled component or interface hides. A reversed range such as `L9-7` exits 1 while parsing, with or without `--source`; a citation past the file's last line and `L0` are only detectable against a source file, so they exit 1 only under `--source`. 5. **Change the script.** Add or change a test in `scripts/test_interface_matrix.py` first and watch it fail, then change `scripts/interface_matrix.py`, then rerun the health check. Keep the diff --git a/skills/interface-matrix/scripts/interface_matrix.py b/skills/interface-matrix/scripts/interface_matrix.py index b400f80..37cd0e4 100644 --- a/skills/interface-matrix/scripts/interface_matrix.py +++ b/skills/interface-matrix/scripts/interface_matrix.py @@ -669,6 +669,14 @@ def main(argv=None): ap.add_argument("--sample", type=nonneg, default=20, help="unstated pairs to print (0 = all)") ap.add_argument("--source", help="source file whose L citations to check for coverage") args = ap.parse_args(argv) + # Windows consoles default to legacy codepages (cp437/cp850/cp1252) and CI pipes + # may be plain ASCII; writing the report raw then dies mid-report with + # UnicodeEncodeError. Reconfigure stdout to UTF-8 so the report is byte-identical + # whatever the console or pipe claims (Python 3.7+; 3.9 is the floor). + try: + sys.stdout.reconfigure(encoding="utf-8") + except (AttributeError, ValueError, OSError): + pass with open(args.input, encoding="utf-8") as fh: text = fh.read() components, interfaces, rules, cites, has_rules = parse(text) diff --git a/skills/interface-matrix/scripts/test_interface_matrix.py b/skills/interface-matrix/scripts/test_interface_matrix.py index ba55b0e..7fc9a23 100644 --- a/skills/interface-matrix/scripts/test_interface_matrix.py +++ b/skills/interface-matrix/scripts/test_interface_matrix.py @@ -965,6 +965,40 @@ def test_no_source_flag_means_no_coverage_section(self): proc = run(doc(IFACE)) self.assertNotIn("## 10.", proc.stdout) + def test_report_survives_a_non_utf8_console(self): + # Windows consoles default to legacy codepages (cp437 has no em-dash) and + # pipes can be ASCII; the script must reconfigure stdout to UTF-8 rather + # than crash mid-report with UnicodeEncodeError. The em-dash lives in the + # --source coverage section, so this drives that path (fails without the + # reconfigure in main()). + src = os.path.join(tempfile.mkdtemp(), "inventory.md") + with open(src, "w", encoding="utf-8") as fh: + fh.write(doc(IFACE) + "\nsome uncited prose below the tables\n") + try: + proc = subprocess.run( + [ + sys.executable, + "-c", + "import sys, io, os, runpy\n" + 'sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="cp437",' + ' errors="strict")\n' + 'script = os.path.abspath(sys.argv[1])\n' + 'sys.argv = ["interface_matrix.py", sys.argv[2],' + ' "--source", sys.argv[2]]\n' + 'runpy.run_path(script, run_name="__main__")\n', + SCRIPT, + src, + ], + capture_output=True, + text=True, + ) + finally: + os.unlink(src) + os.rmdir(os.path.dirname(src)) + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("Nothing cites these spans", proc.stdout) + self.assertNotIn("UnicodeEncodeError", proc.stderr) + if __name__ == "__main__": unittest.main(verbosity=2)