diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 6609ae2..5ef408b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "ia", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "source": "./" } diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6609ae2..5ef408b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "ia", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "source": "./" } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4f6474d..6f079cc 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "homepage": "https://github.com/programmersio-ibmi/skills", "license": "MIT", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 1f22df3..02f7b3d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis for coding agents.", "homepage": "https://github.com/programmersio-ibmi/skills", "license": "MIT", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 2002818..be5a415 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ia", "displayName": "iA — IBM i Impact Analysis", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "publisher": "programmersio", "homepage": "https://github.com/programmersio-ibmi/skills", diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 6609ae2..5ef408b 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "ia", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "source": "./" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 28551e4..5677a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] — 2026-06-11 + +### Added + +- **Visual flowchart generation** (`references/flowchart.md`): produce a single-page HTML flowchart of a program — caller context, subroutine/procedure flow, and external-call nodes — from `references/templates/flowchart-template.html`, gated by the `scripts/validate_flowchart.py` lint check. +- **Test case document generation** (`references/test-case-generation.md`): a fifth document type (QA/UAT test scripts) alongside the four audience templates, built from `references/templates/template-testcases.md` and gated by the `scripts/validate_testcases.py` lint check. + +### Changed + +- Richer Word/PDF export from the markdown converter scripts: branded cover page, styled tables/headings, and broader markdown coverage in both `convert_md_to_docx.py` and `convert_md_to_pdf.py`. +- `SKILL.md` references table, templates README, and the program-documentation workflow refreshed to route to the new flowchart and test-case capabilities. + ## [1.0.2] — 2026-06-02 ### Changed diff --git a/gemini-extension.json b/gemini-extension.json index 0c0f8ca..ae4b9ff 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "contextFileName": "GEMINI.md" } diff --git a/package.json b/package.json index fc774cc..cd8c42e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ia-tools-skill", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "license": "MIT", "type": "module", diff --git a/plugin.json b/plugin.json index 416198d..10610b0 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "ia", - "version": "1.0.2", + "version": "1.1.0", "description": "IBM i Impact Analysis — dependency tracing, field impact, call hierarchy, program documentation.", "publisher": "programmersio", "homepage": "https://github.com/programmersio-ibmi/skills", diff --git a/skills/ia/SKILL.md b/skills/ia/SKILL.md index 07e58b4..c55fc23 100644 --- a/skills/ia/SKILL.md +++ b/skills/ia/SKILL.md @@ -141,3 +141,5 @@ Do not attempt to diagnose server-side issues or retry indefinitely. | Complex analysis chains | [query-flows.md](references/query-flows.md) | | Analysis playbooks | [playbook.md](references/playbook.md) | | Program documentation | [program-documentation.md](references/program-documentation.md) | +| Test case document for a program (QA/UAT scripts) | [test-case-generation.md](references/test-case-generation.md) | +| Visual flowchart of a program (single-page HTML) | [flowchart.md](references/flowchart.md) | diff --git a/skills/ia/references/flowchart.md b/skills/ia/references/flowchart.md new file mode 100644 index 0000000..2ec65a8 --- /dev/null +++ b/skills/ia/references/flowchart.md @@ -0,0 +1,146 @@ +# Flowchart Generation + +Use this when a user asks for a **visual flowchart** of an IBM i program ("flowchart for X", "diagram the logic of X", "visualize the flow"). The deliverable is **one self-contained HTML file** holding a **single top-to-bottom Mermaid diagram** of the whole program, plus a subroutine index and file list. + +> **One diagram, HTML only.** Mermaid renders live in the browser, so it stays out of the text-based Word/PDF spec pipeline (which uses ASCII trees — see [program-documentation.md](program-documentation.md), Step 6). For a full written spec, use program documentation; for a visual flow, use this. + +--- + +## 1. Gather the data (reuse the spec tools) + +One call gets almost everything: + +``` +ia_program_spec_bundle(program_name=X) → LOOKUP, COMPLEXITY, FILES, SUBROUTINES, CALLEES, PARAMS +ia_subroutines(member_name=X) → BEGSR with exact line numbers (for the "L" refs) +ia_call_hierarchy(program_name=X, direction='CALLERS') → who calls X (the "Called by" meta line) +``` + +If LOOKUP returns multiple versions, **stop and ask which library** (same rule as program docs). Read the **source** (`ia_rpg_source` / `ia_cl_source`) to follow the actual control flow — the diagram must reflect what the code does, not a guess. Collect: program name/library/type, total + executable lines, last-changed date, the subroutine list with line numbers, DB files, external calls, callers, and the entry interface (`*ENTRY`/PI parameters from PARAMS — or "no parameters — interactive"). While reading the source, also note the **F-keys** the program handles (F3 exit, F4 prompt, F12 cancel, …) for the F-key row. + +> **Procedure-based programs** (COMPLEXITY shows `PROC>0` with `SBR=0` — common in SQLRPGLE): `ia_subroutines` and the bundle's `SUBROUTINES` section come back empty. Build the blocks from the program's **procedures** instead — list them via `ia_procedure_params` (`PROCEDURE_NAME`) / `ia_procedure_xref`, and read source (`dcl-proc`) for line ranges. + +--- + +## 2. Design ONE top-to-bottom diagram + +A single `flowchart TD`, the **whole program in one graph**, reading top → bottom in execution order: `START` at the top, the mainline/menu, then each branch flowing **downward**, and `END` reached from the program's exit. + +- **Stay at a high altitude — ~15–25 nodes for the whole program.** Show the mainline and each option/branch's key steps (the screens, the decisions, the DB I/O). **Collapse helper subroutines** (clear/reset/refresh, window load+display) into one representative node — the *full* subroutine list lives in the table below the diagram, not in boxes. This is what lets one diagram cover even a large program. +- **No `subgraph`s.** They make Mermaid cluster areas side-by-side and destroy the top-to-bottom flow. Keep it one flat graph; the `class` colors already group nodes visually. +- **External program calls are never collapsed.** Every `CALL`/`CALLP` to another *program* gets its own node in subroutine shape `[["CALL PGMX
L.."]]` with class `extcall` — the hand-off to another program is exactly what a new developer must see. +- **Loops read downward:** draw loop-backs (e.g. "return to menu after each action") as **dotted** edges `-.->|loop|` to the loop entry, and the program exit as a **thick** edge `==>|Yes|` to `END`. The solid forward arrows then carry the eye straight down. +- **One `START`, one `END`.** Every block carries a line reference (`
L131` or `
L272-320`). +- Decisions are diamonds; label both outgoing edges. + +--- + +## 3. Mermaid rules (v10.9.0) + +The template pins `mermaid@10.9.0` — **do not change it** (v11.x has SVG layout issues). + +``` +flowchart TD + NODE(["Terminal"]) ← START / END (rounded) + NODE["Process"] ← subroutine / process / message (rectangle) + NODE[/"Input/Output"/] ← EXFMT / DISPLAY screen (parallelogram) + NODE[("Database")] ← CHAIN / READ / WRITE / SETLL (cylinder) + NODE[["CALL PGMX"]] ← external program call (subroutine shape) + NODE{"Decision?"} ← IF / loop test / validation (diamond) + A --> B ← forward step + A -->|Label| B ← labelled step + A -.->|loop| B ← loop-back (dotted) + A ==>|Yes| B ← program exit (thick) +``` + +- Always `flowchart TD`, never `graph TD`. Double quotes in labels; line breaks with `
`. +- Node names: alphanumeric + underscore only. Avoid the reserved word `end` as a node id (use `ENDP`). +- Escape `&` as `&`. Keep labels short — push detail into the table. + +--- + +## 4. Colours — define once with `classDef` + +Put these **seven `classDef` lines once** at the end of the diagram, then assign nodes with `class`. (Don't repeat per-node `style` lines.) Dark fills + white text are WCAG-AA. + +``` +classDef terminal fill:#2E7D32,stroke:#1B5E20,color:#fff,stroke-width:2px; +classDef process fill:#1565C0,stroke:#0D47A1,color:#fff,stroke-width:2px; +classDef display fill:#0277BD,stroke:#01579B,color:#fff,stroke-width:2px; +classDef db fill:#6A1B9A,stroke:#4A148C,color:#fff,stroke-width:2px; +classDef extcall fill:#00695C,stroke:#004D40,color:#fff,stroke-width:2px; +classDef decision fill:#F57F17,stroke:#E65100,color:#fff,stroke-width:2px; +classDef error fill:#C2185B,stroke:#880E4F,color:#fff,stroke-width:2px; +class START,ENDP terminal; +class A,B process; +... +``` + +| Class | Use for | +|-------|---------| +| `terminal` | START / END / ENTRY / RETURN | +| `process` | subroutine / process / status message | +| `display` | EXFMT / screen / subfile | +| `db` | CHAIN / READ / WRITE / SETLL / SQL | +| `extcall` | CALL / CALLP to another program | +| `decision` | IF / loop test / validation | +| `error` | error path / failure message | + +--- + +## 5. Build the page (copy the template) + +**Copy the template — never write the HTML from scratch:** + +``` +templates/flowchart-template.html → docs/program-specs/{PROGRAM_NAME}/{PROGRAM_NAME}_Flowchart.html +``` + +Replace only the content; leave the CSS and the one-line `mermaid.initialize` script alone. The page has exactly these parts: + +1. **Title / `

` / one-line subtitle.** +2. **Meta strip** — line 1: library · source member · type · lines (total + exec) · subroutine count · files · last updated. **Line 2:** Called by `` · Calls `` · Entry: `<*ENTRY/PI params, or "no parameters — interactive">`. +3. **The diagram** (§2–4). +4. **Legend** — the seven colours + a note that dotted = loop-back, **plus an F-key row** listing only the keys the program actually handles (`F3 Exit · F4 Lookup window · F12 Back to menu`). +5. **What it does** — 2–3 sentences in plain language. +6. **Subroutines (source index)** — one row per BEGSR (or procedure): name · line · area · **in diagram** (the node that represents it — its own node, or the node it's collapsed under) · purpose. *This is where the full detail lives.* +7. **Files** — file · type/access · record format(s) · role. +8. **Footer** — print note + "flowchart by iA from programmers.io". + +There are **no tabs, no zoom controls, and no print hook** — a single diagram renders on load, so `Ctrl+P` prints/saves the whole page (the print CSS just scales the SVG to the page width). + +--- + +## 6. Verify before delivery — two gates + +**Gate 1 — lint (mandatory, run first):** + +``` +python scripts/validate_flowchart.py docs/program-specs/{PGM}/{PGM}_Flowchart.html +``` + +Must exit 0. It catches: not `flowchart TD`, `subgraph`s, the reserved `end` node id, leftover template placeholders, nodes with no `class` assigned, an unknown/undefined class, an unpinned Mermaid version, and bare `&` in labels. + +**Gate 2 — browser:** open the file and confirm: + +- [ ] The diagram **flows top → bottom**: `START` at top, branches descending, `END` reached via the exit edge; dotted arrows are the loop-backs. +- [ ] It renders (not raw code); colours show. +- [ ] Altitude is right — roughly 15–25 nodes; helper routines are collapsed, not drawn individually. +- [ ] Every external `CALL` appears as a teal `[["CALL …"]]` node — program calls are never collapsed. +- [ ] **Every** subroutine/procedure appears in the index table with its line number, and its **In diagram** column names a real node from the diagram. +- [ ] Meta strip (both lines), F-key row, and file list match the iA data and the source. +- [ ] **Ctrl+P** preview shows the whole diagram on the page. + +Save to `docs/program-specs/{PROGRAM_NAME}/{PROGRAM_NAME}_Flowchart.html`. + +## Common errors + +| Symptom | Fix | +|---------|-----| +| Areas sit side-by-side, flow isn't vertical | Remove `subgraph`s — use one flat `flowchart TD`. | +| Diagram is a wall of boxes | Too low-altitude: collapse helper subroutines into representative nodes; move detail to the table. | +| Mermaid syntax error | `flowchart TD` (not `graph TD`), double quotes, `
` for breaks, no node id named `end`, `classDef` lines last. | +| Text invisible on a node | You used a bare node with no `class` — assign one of the seven classes. | +| An external CALL is invisible in the flow | Give it its own `[["CALL PGMX"]]` node with class `extcall` — never fold a program call into another node. | +| Lint (Gate 1) fails | Read its findings — each maps to a row above or a leftover template placeholder. Fix and re-run before the browser check. | +| Diagram broke after edits | You changed the ` + + + + + +

[PROGRAM_NAME]

+

[One-line description of what this program does.] One-diagram view, top-to-bottom flow.

+ + +
+ [LIBRARY] · source [SOURCE_MEMBER] · [TYPE] · [NNN] lines ([NNN] executable) · + [NN] subroutines · [N PF + N DSPF] · [N] external calls · updated [YYYY-MM-DD]
+ Called by [CALLERS or none] · Calls [CALLEES or none] · Entry: [*ENTRY/PI params, or "no parameters — interactive"] +
+ + +
+
+flowchart TD + START(["START"]) + INIT["[Initialization]
L[x-y]"] + MENU[/"[EXFMT main screen]
L[x]"/] + EXITQ{"[Exit key pressed?]
L[x]"} + ROUTE{"[Which option?]
L[x-y]"} + OPT1[/"[Option 1 — screen]
L[x]"/] + READ1[("[CHAIN / READ file]
L[x]")] + FOUND{"[Found?]
L[x]"} + SHOW1[/"[Show / process record]
L[x-y]"/] + NF["[Not-found message]
L[x]"] + OPT2[/"[Option 2 — screen]
L[x]"/] + VAL{"[Valid?]
L[x-y]"} + WRITE2[("[WRITE / UPDATE file]
L[x]")] + OK["[Success message]
L[x]"] + INVALID["[Invalid option message]
L[x]"] + ENDP(["END
L[x]"]) + + START --> INIT --> MENU --> EXITQ + EXITQ -->|No| ROUTE + ROUTE -->|invalid| INVALID + ROUTE -->|"[opt 1]"| OPT1 + ROUTE -->|"[opt 2]"| OPT2 + + OPT1 --> READ1 --> FOUND + FOUND -->|Yes| SHOW1 + FOUND -->|No| NF + + OPT2 --> VAL + VAL -->|invalid| OPT2 + VAL -->|valid| WRITE2 --> OK + +%% If the program CALLs another program, give the call its own node: +%% EXTC[["CALL [PGMX] — [purpose]
L[x]"]] +%% …wire it into the flow and add: class EXTC extcall; + + INVALID -.->|loop| MENU + SHOW1 -.->|loop| MENU + NF -.->|loop| MENU + OK -.->|loop| MENU + EXITQ ==>|Yes| ENDP + + classDef terminal fill:#2E7D32,stroke:#1B5E20,color:#fff,stroke-width:2px; + classDef process fill:#1565C0,stroke:#0D47A1,color:#fff,stroke-width:2px; + classDef display fill:#0277BD,stroke:#01579B,color:#fff,stroke-width:2px; + classDef db fill:#6A1B9A,stroke:#4A148C,color:#fff,stroke-width:2px; + classDef extcall fill:#00695C,stroke:#004D40,color:#fff,stroke-width:2px; + classDef decision fill:#F57F17,stroke:#E65100,color:#fff,stroke-width:2px; + classDef error fill:#C2185B,stroke:#880E4F,color:#fff,stroke-width:2px; + class START,ENDP terminal; + class INIT,OK process; + class MENU,OPT1,SHOW1,OPT2 display; + class READ1,WRITE2 db; + class EXITQ,ROUTE,FOUND,VAL decision; + class INVALID,NF error; +
+
+ + +
+ Terminal + Process + Display (EXFMT) + Database I/O + External call + Decision + Error / message + · · · dotted = loop back +
+
+ F-keys: [F3 Exit · F4 Lookup window · F12 Back to menu — only keys this program handles] +
+ + +

What it does

+

[Two or three sentences: the program's purpose, its main options/branches, the files it reads and writes, and how it ends. Reference key file/field names in code font.]

+ + +

Subroutines (source index)

+ + + + + + +
SubroutineLineAreaIn diagramPurpose
[NAME][NNN][Main/Display/Add/...][node label, or "collapsed under "][one-line purpose]
+ + +

Files

+ + + + + + +
FileType / AccessRecord format(s)Role
[FILE][Physical file · update/output][RCDFMT][role]
[DSPF][Display file · EXFMT/subfile][formats][role]
+ + +

Single rendered diagram — Ctrl+P prints/saves as PDF directly. Generated [YYYY-MM-DD] · [PROGRAM_NAME] · [LIBRARY] · flowchart by iA from programmers.io

+ + + + diff --git a/skills/ia/references/templates/template-testcases.md b/skills/ia/references/templates/template-testcases.md new file mode 100644 index 0000000..53c4dd0 --- /dev/null +++ b/skills/ia/references/templates/template-testcases.md @@ -0,0 +1,174 @@ +# Test Case Document — + +**Author:** iA by programmers.io +**Date:** +**Library version documented:** | **Source file:** | **Member type:** +**Audience:** QA / UAT Testers + +--- + +## 1. Introduction + +**Program under test:** ` ()` + + + +**Scope of this document:** Manual execution test cases covering . Test cases are derived from program source analysis by iA; every functional case traces to a documented business rule (BR-xxx) with its source line anchor. + +**How to use this document:** +1. Complete the environment setup in Section 2 and seed the data in Section 3. +2. Execute test cases in Section 4 in order within each category. +3. Record the actual result, status (Pass / Fail / Blocked), tester, and date in each case's execution strip. +4. Total the results in Section 6 and obtain sign-off. + +--- + +## 2. Test Environment & Prerequisites + +| Requirement | Value | +|-------------|-------| +| Test library (program) | `` | +| Library list must include | | +| User authority | | +| Display device | 5250 session, 24x80 | + +**Required objects:** + +| Object | Library | Type | Role in test | +|--------|---------|------|--------------| +| | | *PGM | Program under test | +| | | *FILE () | | + + + +**General preconditions (apply to every test case):** + +--- + +## 3. Test Data Preparation + +> **Note:** All data values below are synthetic, derived from the file field definitions. **Replace with environment-specific values where noted** — key values must not collide with records your environment already contains unless a test explicitly requires an existing record. + +### Data Set A — + +| File (Library) | Field | Value | Why this value | +|----------------|-------|-------|----------------| +| () | () | | | + +### Data Set B — + +| Field | Value | Why this value | +|-------|-------|----------------| +| () | | | + + + +--- + +## 4. Test Cases + +-001, -002, …), not per category.> + +### 4.1 Screen Navigation & Function Keys + +### TC--001 — + +| Field | Value | +|---|---| +| Category | Screen Navigation & Function Keys | +| Priority | | +| Traces to | | +| Preconditions | | +| Test data | | + +**Steps** +1. from command line / type value in field / press key> +2. + +**Expected result** + + +| Actual result | Status | Tester | Date | +|---|---|---|---| +| | | | | + +### 4.2 Field Validation + + + +### 4.3 Business Rule / Functional + + + +### 4.4 File I/O & Data + + + +### 4.5 Boundary + + + +### 4.6 Parameter + + + +--- + +## 5. Traceability Matrix + +**Business rule → test case coverage:** + +| Business Rule | Description | Covered by | Coverage | +|---------------|-------------|------------|----------| +| BR-001 | | TC--003, TC--004 | ✅ | +| BR-002 | | TC--005 | ✅ | + +**Coverage:** of business rules covered (%). + +**Test case → source traceability:** every test case's "Traces to" row carries the BR id or source anchor it verifies; navigation cases trace to the screen-flow source lines. + +--- + +## 6. Execution Summary & Sign-off + +### Execution Summary + +| Category | Total | Passed | Failed | Blocked | +|----------|-------|--------|--------|---------| +| Screen Navigation & Function Keys | | | | | +| Field Validation | | | | | +| Business Rule / Functional | | | | | +| File I/O & Data | | | | | +| Boundary | | | | | +| Parameter | | | | | +| **Total** | **** | | | | + + + +### Sign-off + +| Role | Name | Signature | Date | +|------|------|-----------|------| +| Tested by | | | | +| Reviewed by | | | | +| Approved by | | | | + +--- + +## Documentation Quality Report + +| Metric | Score | Status | +|--------|-------|--------| +| BR coverage | % | ✅/⚠️ / business rules have ≥1 test case | +| F-key coverage | % | ✅/⚠️ / handled function keys have a test case | +| Output/Update file coverage | % | ✅/⚠️ / written files have ≥1 test case | +| Source traceability | ✅/⚠️ | Every TC traces to a BR or source anchor: YES/NO | +| Lint gate | ✅/❌ | `validate_testcases.py` exit code 0: YES/NO | +| Freshness | Current | ✅ Generated | + +**Validation Warnings (if any):** +- + +--- + +*Analysis powered by iA from [programmers.io](https://programmers.io/ia/)* diff --git a/skills/ia/references/test-case-generation.md b/skills/ia/references/test-case-generation.md new file mode 100644 index 0000000..a2649b4 --- /dev/null +++ b/skills/ia/references/test-case-generation.md @@ -0,0 +1,121 @@ +# Test Case Generation Reference + +Use this guide when the Step 1.5 DocType is **`Test_Case_Document`** — the user asked for "test cases", "test scripts", "UAT scripts", or a "test case document" for an IBM i program. The deliverable is a manual QA/UAT execution document built on [`templates/template-testcases.md`](templates/template-testcases.md). + +The workflow **reuses program-documentation.md Steps 1.5–3 and 7.5–9 unchanged** (existence check, TodoWrite kickoff, `ia_program_spec_bundle`, silent `ia_file_fields` lookup, source + BR-xxx extraction, filename gate, save, export). This reference replaces Steps 4–7 (assembly + verification) for this DocType only. No new `ia_*` tools are involved. + +**Audience calibration:** the executor is a QA/UAT tester at a 5250 session — not a developer. Steps say exactly what to type and press; expected results say exactly what to observe. No RPG jargon in steps or expected results (subroutine names belong in "Traces to", not in the instructions). + +--- + +## 1. Inputs (already gathered by Steps 2–3) + +| Input | Source | Feeds | +|-------|--------|-------| +| Program metadata, files, params, subroutines | `ia_program_spec_bundle` | §1 Introduction, §2 Environment, category selection | +| File + field descriptions | `ia_program_files` + silent `ia_file_fields` lookup | §2 required objects, §3 data values, every field mention | +| BR-xxx list with line anchors | Step 3 source extraction | §4 functional cases, §5 traceability matrix | +| Handled function keys + EXFMT flow | Step 3 source read (CFnn/CA keywords, F-key handling logic) | §4.1 navigation cases | +| Validation logic, message text, file opcodes (CHAIN/WRITE/UPDATE/DELETE) | Step 3 source read | §4.2–4.5 cases, expected results | + +If BR extraction produced zero rules for a program with executable logic, stop and re-run Step 3 — a test case document without BR-driven cases is not deliverable. + +--- + +## 2. Derivation rules — source evidence → test cases + +Generate cases **only from source evidence**. A category with no evidence is omitted from the document entirely (no empty headings, no padding). + +| Source evidence | Test cases generated | Category | +|-----------------|----------------------|----------| +| BR-xxx [VALIDATION] | 1 positive (value accepted) + 1 negative (value rejected, error observed) | Field Validation or Business Rule / Functional | +| BR-xxx [CALCULATION] | 1 case with concrete synthetic inputs → expected computed value (compute it yourself from the rule; show the arithmetic in the expected result) | Business Rule / Functional | +| BR-xxx [STATUS / FLAG, CLASSIFICATION, EXCEPTION, ACCUMULATION] | 1 case per distinct behavior branch the rule drives | Business Rule / Functional | +| Each handled F-key (CFnn/CA + handling logic) | 1 case: press the key, observe the documented behavior | Screen Navigation & Function Keys | +| EXFMT screen flow | 1 entry case (call program, initial screen correct) + 1 exit case (leave program cleanly) | Screen Navigation & Function Keys | +| Output/Update file | create path + update path + not-found path (from CHAIN/%FOUND handling); duplicate-key case **only** if source shows duplicate handling | File I/O & Data | +| Validated field's type/length/decimals (`ia_file_fields`) | boundary cases: maximum value/length, zero, blank — only for fields the program actually validates | Boundary | +| Entry parameters (PARAMS / `ia_procedure_params`) | 1 valid-call case + 1 invalid/missing-parameter case | Parameter | + +**Anti-explosion rule:** one test case per validation *rule*, not per field instance. If the same rule applies to several fields (e.g. "required field" on five fields), write **one** case listing the affected fields in the test data row. Target a focused document — quality of evidence over case count. + +**Coverage floors (hard — generation errors if missed):** +- every BR-xxx has ≥ 1 test case +- every handled F-key has 1 test case +- every Output/Update file has ≥ 1 test case + +--- + +## 3. Synthetic test data construction + +All data values are synthetic, built from `ia_file_fields` metadata. Never copy live repository data into the deliverable. + +| Field metadata | Valid value | Invalid / boundary values | +|----------------|-------------|---------------------------| +| Packed/zoned `7,2` | mid-range, e.g. `1250.00` | max `99999.99`, zero, negative (if source rejects) | +| Char `10` code field | a plausible 10-char (or shorter) code | blank, lowercase (if source uppercases/rejects), value absent from the file (not-found path) | +| Date field | a valid date in the program's format | invalid date (e.g. month 13) **only if source validates dates** | +| Key field | value seeded in Data Set A (exists) | value reserved in Data Set B (must not exist) | + +Rules: +- Use the field's **FIELD_TEXT description** to make values plausible (a `CUSTNO (Customer Number)` gets a numeric-looking key, not `XXXXX`). +- Every value used in a test case step must appear in a §3 data set (existing-record sets vs must-not-exist sets), so the tester can seed before executing. +- Flag each data set with *"replace with environment-specific values where noted"* — key values are suggestions, the structure is the contract. +- Invalid values must violate **exactly one** rule at a time — a value that is both too long and non-numeric proves nothing. + +--- + +## 4. Assembly rules (template-testcases.md) + +- **TC ID:** `TC-{PGM}-{NNN}`, three digits, sequential across the **whole document** starting at 001 — not per category. +- **Category order:** Screen Navigation & Function Keys → Field Validation → Business Rule / Functional → File I/O & Data → Boundary → Parameter. Omit empty categories. +- **Priority is derived, never invented:** High = BR validations + file-update integrity; Medium = navigation / boundary; Low = display-only behavior. +- **Expected results are source-evidence-only.** Use the actual message text found in source constants or message-file references. If the exact text is not in source, write *"error indicated — verify exact message text in environment"*. Never invent message text, screen layouts, or behaviors. +- **Steps are executable as written:** start from the general preconditions, name the exact field labels and keys (`press F3`, not "exit"). A tester who has never seen the program must be able to run them. +- **Traces to** carries the BR id with its line anchor — `BR-002 (line 145, VALIDCUST)` — or a screen-flow/source anchor for navigation cases. +- **Field-format + library rules apply:** every field mention is `FIELDNAME (Field Description)`, every file mention is `FILE (LIBRARY)` — same as all other DocTypes. +- **Execution strips, summary totals (Passed/Failed/Blocked), and sign-off rows ship blank** — they belong to the tester. +- **Traceability matrix (§5)** lists every BR-xxx with the TC ids covering it; coverage must read 100% (an uncovered BR is a generation error, fix before delivery). + +--- + +## 5. Verification (Step 7 for this DocType) + +Two gates, in order — same pattern as the flowchart deliverable. + +**Gate 1 — lint script (run first):** + +```bash +python scripts/validate_testcases.py docs/program-specs/{PGM}/{PGM}_Test_Case_Document.md +``` + +Exit 0 = pass. Exit 1 = findings printed one per line; fix every finding and re-run until clean. Never deliver on a failing lint. + +**Gate 2 — content checks (manual, all must pass):** + +| Check | Rule | Action if failed | +|-------|------|------------------| +| BR coverage floor | Every BR-xxx from Step 3 has ≥1 TC; matrix shows 100% | **ERROR** — add missing cases | +| F-key coverage floor | Every handled F-key has a TC | **ERROR** — add missing cases | +| File coverage floor | Every Output/Update file has ≥1 TC | **ERROR** — add missing cases | +| Expected-result evidence | Message text matches source, or carries the "verify exact message text" flag | **ERROR** — fix or flag | +| Data set completeness | Every value in a TC's test data row appears in a §3 data set | **ERROR** — add to data set | +| Category honesty | No empty category headings; no padded/invented cases | **ERROR** — remove | +| Tester readability | Steps contain no RPG jargon; each step is one concrete action | **WARNING** — rewrite | + +Then continue with program-documentation.md Step 7.5 (filename gate → `{PGM}_Test_Case_Document.md`), Step 8 (save + branding + quality report), and Step 9 (DOCX/PDF export on request). + +--- + +## 6. Common traps + +| Trap | Symptom | Fix | +|------|---------|-----| +| Invented message text | Expected result quotes an error message that is not in source | Quote source text only, or use the "verify exact message text in environment" flag | +| Case explosion | 5 identical required-field cases for 5 fields | One case per rule, affected fields listed in test data | +| Padded categories | "Parameter" section for a program with no entry parameters | Omit the category entirely | +| Orphan test data | A step types a value that no §3 data set defines | Every step value traces to a data set | +| Uncovered BR | Matrix shows a BR with no TC | Coverage floor is hard — add the case before delivery | +| Per-category numbering | TC-PGM-001 appears in two categories | Numbering is document-global and sequential | +| Developer-speak in steps | "Trigger the VALIDCUST subroutine" | Steps describe screen actions; subroutine names live in "Traces to" | +| Pre-filled execution strip | Status column contains "Pass" on delivery | Execution strips ship blank | diff --git a/skills/ia/scripts/convert_md_to_docx.py b/skills/ia/scripts/convert_md_to_docx.py index d8bc257..73f43a2 100644 --- a/skills/ia/scripts/convert_md_to_docx.py +++ b/skills/ia/scripts/convert_md_to_docx.py @@ -170,6 +170,9 @@ def strip_markdown_formatting(text: str) -> str: _XML_BAD = re.compile(r'[\x00-\x08\x0B\x0C\x0E-\x1F]') +# Markdown backslash-escapable punctuation (\< \> \| \* etc.) +MD_ESCAPE_RE = re.compile(r'\\([\\`*_{}\[\]()#+\-.!|<>])') + def _xml_clean(s: str) -> str: return _XML_BAD.sub('', s) if s else s @@ -193,12 +196,18 @@ def parse_inline_formatting(para, text: str): bold_spans: List[str] = [] italic_spans: List[str] = [] link_spans: List[Tuple[str, str]] = [] + esc_spans: List[str] = [] def save_code(m): idx = len(code_spans) code_spans.append(m.group(1)) return f'\x00C{idx}\x00' + def save_esc(m): + idx = len(esc_spans) + esc_spans.append(m.group(1)) + return f'\x00E{idx}\x00' + def save_link(m): idx = len(link_spans) link_spans.append((m.group(1), m.group(2))) @@ -222,6 +231,9 @@ def save_italic(m): # Code spans (handle multi-tick fences first then single-tick) text = re.sub(r'``([^`]+)``', save_code, text) text = re.sub(r'`([^`\n]+)`', save_code, text) + # Markdown backslash escapes -> literal character (protected from + # the formatting regexes below, e.g. \< \> \| \*) + text = MD_ESCAPE_RE.sub(save_esc, text) # Links text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', save_link, text) # Bold+italic @@ -235,7 +247,7 @@ def save_italic(m): # Decode placeholders — recursively, since bold/italic spans may contain # nested code-span or link placeholders that were saved earlier. - placeholder_re = re.compile(r'(\x00[CLXBI]\d+\x00)') + placeholder_re = re.compile(r'(\x00[CLXBIE]\d+\x00)') def render(segment: str, bold: bool = False, italic: bool = False): if not segment: @@ -243,7 +255,7 @@ def render(segment: str, bold: bool = False, italic: bool = False): for part in placeholder_re.split(segment): if not part: continue - m = re.match(r'\x00([CLXBI])(\d+)\x00$', part) + m = re.match(r'\x00([CLXBIE])(\d+)\x00$', part) if not m: run = para.add_run(_xml_clean(part)) if bold: @@ -270,6 +282,12 @@ def render(segment: str, bold: bool = False, italic: bool = False): render(bold_spans[idx_s], bold=True, italic=italic) elif kind == 'I': render(italic_spans[idx_s], bold=bold, italic=True) + elif kind == 'E': + run = para.add_run(_xml_clean(esc_spans[idx_s])) + if bold: + run.bold = True + if italic: + run.italic = True render(text) @@ -277,17 +295,27 @@ def render(segment: str, bold: bool = False, italic: bool = False): # ── Tables ───────────────────────────────────────────────────────────── +def _split_md_row(line: str) -> List[str]: + """Split a table row honoring escaped pipes and optional trailing pipe.""" + line = line.strip().replace('\\|', '\x00PIPE\x00') + if line.startswith('|'): + line = line[1:] + if line.endswith('|'): + line = line[:-1] + return [c.replace('\x00PIPE\x00', '\\|').strip() for c in line.split('|')] + + def parse_table(lines: List[str], start_idx: int) -> Tuple[List[List[str]], int]: rows = [] idx = start_idx while idx < len(lines): line = lines[idx].rstrip() - if not line.startswith('|'): + if not line.lstrip().startswith('|'): break if re.match(r'^[\|\-\:\s]+$', line): idx += 1 continue - cells = [c.strip() for c in line.split('|')[1:-1]] + cells = _split_md_row(line) if cells: rows.append(cells) idx += 1 @@ -544,6 +572,11 @@ def add_list_items(doc, items: List[Dict]): text = item['text'] level = min(max(item['level'], 0), 3) + # Returning to a shallower level restarts any deeper ordered runs + for lv in list(counters.keys()): + if lv > level: + counters.pop(lv) + p = doc.add_paragraph() p.paragraph_format.space_before = Pt(0) p.paragraph_format.space_after = Pt(2) @@ -551,6 +584,14 @@ def add_list_items(doc, items: List[Dict]): # Hanging indent so wrapped lines align under the text, not the marker p.paragraph_format.first_line_indent = Cm(-0.55) + # Task-list checkboxes: - [x] done / - [ ] open + checkbox = None + if not item['ordered']: + m_cb = re.match(r'^\[( |x|X)\]\s+(.*)$', text) + if m_cb: + checkbox = '☑' if m_cb.group(1).lower() == 'x' else '☐' + text = m_cb.group(2) + if item['ordered']: # Restart counter for this level the first time we hit it. # If user explicitly numbered the first item (e.g. starts at 5), @@ -565,10 +606,7 @@ def add_list_items(doc, items: List[Dict]): counters[level] += 1 marker = f'{n}.' else: - # Bullets reset any deeper-level ordered counters - counters.pop(level + 1, None) - counters.pop(level + 2, None) - marker = bullets_by_level[min(level, len(bullets_by_level) - 1)] + marker = checkbox or bullets_by_level[min(level, len(bullets_by_level) - 1)] marker_run = p.add_run(marker + '\t') marker_run.font.name = 'Calibri' @@ -645,6 +683,254 @@ def is_table_start(lines: List[str], i: int) -> bool: return False +# ── Metadata, cover page, TOC, header/footer ────────────────────────── + + +def extract_title(md_content: str) -> str: + for line in md_content.split('\n'): + line = line.strip() + if line.startswith('# '): + return line[2:].strip() + return 'Technical Document' + + +def extract_metadata(md_content: str) -> Tuple[Dict[str, str], int]: + """Parse the leading '**Key:** value' header block (after the H1 title). + Handles several pairs on one line separated by '|'. + Returns (meta, index_of_first_body_line).""" + meta: Dict[str, str] = {} + lines = md_content.split('\n') + in_header = False + skip_count = 0 + for i, raw in enumerate(lines): + line = raw.strip() + if not line: + if in_header and meta: + skip_count = i + 1 + break + continue + if line.startswith('#'): + if in_header: + skip_count = i + break + continue # H1 title precedes the metadata block + if re.match(r'\*\*[^*]+?:?\*\*:?\s*\S', line): + in_header = True + for seg in re.split(r'\s*\|\s*(?=\*\*)', line): + m = re.match(r'\*\*([^*]+?):?\*\*:?\s*(.+)$', seg.strip()) + if m: + meta[m.group(1).strip()] = m.group(2).strip() + skip_count = i + 1 + elif in_header and line == '---': + skip_count = i + 1 + break + elif in_header: + skip_count = i + break + else: + break + return meta, skip_count + + +_SUBTITLE_KEYS = ('Member type', 'Type', 'Program', 'Source file', 'File', + 'Library version documented', 'Library') + + +def add_cover_page(doc: Document, meta: Dict[str, str], title: str): + """Styled cover: eyebrow, large title, accent rule, metadata block.""" + import datetime as _dt + + eyebrow, main = 'TECHNICAL DOCUMENT', title + for dash in ('—', ' -- ', ' - '): + if dash in title: + left, right = title.split(dash, 1) + if left.strip() and right.strip(): + eyebrow, main = left.strip().upper(), right.strip() + break + + spacer = doc.add_paragraph() + spacer.paragraph_format.space_before = Pt(110) + spacer.paragraph_format.space_after = Pt(0) + + p_eye = doc.add_paragraph() + p_eye.paragraph_format.space_after = Pt(6) + r = p_eye.add_run(eyebrow) + r.font.size = Pt(12) + r.font.bold = True + r.font.color.rgb = COLOR_ACCENT + + p_title = doc.add_paragraph() + p_title.paragraph_format.space_after = Pt(10) + r = p_title.add_run(main) + r.font.size = Pt(34) if len(main) <= 22 else Pt(26) + r.font.bold = True + r.font.color.rgb = COLOR_PRIMARY + + parts = [] + for key in ('Member type', 'Type', 'Program'): + if key in meta: + parts.append(meta[key]) + break + for key in ('Source file', 'File'): + if key in meta: + parts.append(f"Source: {meta[key]}") + break + for key in ('Library version documented', 'Library'): + if key in meta: + parts.append(f"Library: {meta[key]}") + break + if parts: + p_sub = doc.add_paragraph() + p_sub.paragraph_format.space_after = Pt(4) + r = p_sub.add_run(' | '.join(parts)) + r.font.size = Pt(12) + r.font.color.rgb = COLOR_SECONDARY + + rule = doc.add_paragraph() + rule.paragraph_format.space_after = Pt(24) + pPr = rule._p.get_or_add_pPr() + pBdr = OxmlElement('w:pBdr') + bottom = OxmlElement('w:bottom') + bottom.set(qn('w:val'), 'single') + bottom.set(qn('w:sz'), '12') + bottom.set(qn('w:space'), '1') + bottom.set(qn('w:color'), '3182CE') + pBdr.append(bottom) + pPr.append(pBdr) + + shown_date = False + for key, value in meta.items(): + if key in _SUBTITLE_KEYS: + continue + if key == 'Date': + shown_date = True + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(3) + r = p.add_run(f'{key}: ') + r.font.size = Pt(10.5) + r.font.bold = True + r.font.color.rgb = COLOR_SECONDARY + r = p.add_run(value) + r.font.size = Pt(10.5) + r.font.color.rgb = RGBColor(0x2d, 0x37, 0x48) + if not shown_date: + p = doc.add_paragraph() + r = p.add_run('Date: ') + r.font.size = Pt(10.5) + r.font.bold = True + r.font.color.rgb = COLOR_SECONDARY + r = p.add_run(_dt.date.today().isoformat()) + r.font.size = Pt(10.5) + + doc.add_page_break() + + +def _add_field(para, instr: str, font_size=Pt(8), color=COLOR_MUTED): + """Insert a Word field (e.g. PAGE, NUMPAGES) as a run.""" + run = para.add_run() + r = run._r + f1 = OxmlElement('w:fldChar') + f1.set(qn('w:fldCharType'), 'begin') + r.append(f1) + it = OxmlElement('w:instrText') + it.set(qn('xml:space'), 'preserve') + it.text = instr + r.append(it) + f2 = OxmlElement('w:fldChar') + f2.set(qn('w:fldCharType'), 'end') + r.append(f2) + run.font.size = font_size + run.font.color.rgb = color + return run + + +def add_toc_page(doc: Document): + """'Contents' heading + a native Word TOC field (levels 1-3).""" + h = doc.add_paragraph() + h.paragraph_format.space_after = Pt(14) + r = h.add_run('Contents') + r.font.size = Pt(20) + r.font.bold = True + r.font.color.rgb = COLOR_PRIMARY + + p = doc.add_paragraph() + run = p.add_run() + r_el = run._r + f1 = OxmlElement('w:fldChar') + f1.set(qn('w:fldCharType'), 'begin') + r_el.append(f1) + it = OxmlElement('w:instrText') + it.set(qn('xml:space'), 'preserve') + it.text = r'TOC \o "1-3" \h \z \u' + r_el.append(it) + f2 = OxmlElement('w:fldChar') + f2.set(qn('w:fldCharType'), 'separate') + r_el.append(f2) + t = OxmlElement('w:t') + t.text = 'Table of contents will populate when fields update (Word asks on open).' + r_el.append(t) + f3 = OxmlElement('w:fldChar') + f3.set(qn('w:fldCharType'), 'end') + r_el.append(f3) + + doc.add_page_break() + + +def enable_update_fields(doc: Document): + """Ask Word to refresh fields (the TOC) when the document opens.""" + element = doc.settings.element + uf = element.find(qn('w:updateFields')) + if uf is None: + uf = OxmlElement('w:updateFields') + element.append(uf) + uf.set(qn('w:val'), 'true') + + +def setup_header_footer(doc: Document, title: str, meta: Dict[str, str]): + """Running header (title + accent rule) and footer (brand | Page X of Y). + The cover page stays clean via different-first-page.""" + from docx.enum.text import WD_TAB_ALIGNMENT + + section = doc.sections[0] + section.different_first_page_header_footer = True + + header = section.header + hp = header.paragraphs[0] + hp.text = '' + r = hp.add_run(title) + r.font.size = Pt(8) + r.font.bold = True + r.font.color.rgb = COLOR_MUTED + pPr = hp._p.get_or_add_pPr() + pBdr = OxmlElement('w:pBdr') + bottom = OxmlElement('w:bottom') + bottom.set(qn('w:val'), 'single') + bottom.set(qn('w:sz'), '8') + bottom.set(qn('w:space'), '4') + bottom.set(qn('w:color'), '3182CE') + pBdr.append(bottom) + pPr.append(pBdr) + + footer = section.footer + fp = footer.paragraphs[0] + fp.text = '' + usable = section.page_width - section.left_margin - section.right_margin + fp.paragraph_format.tab_stops.add_tab_stop(Emu(usable), WD_TAB_ALIGNMENT.RIGHT) + date_str = meta.get('Date', '') + left_text = 'iA by programmers.io' + (f' | {date_str}' if date_str else '') + r = fp.add_run(left_text + '\t') + r.font.size = Pt(8) + r.font.color.rgb = COLOR_MUTED + r = fp.add_run('Page ') + r.font.size = Pt(8) + r.font.color.rgb = COLOR_MUTED + _add_field(fp, 'PAGE') + r = fp.add_run(' of ') + r.font.size = Pt(8) + r.font.color.rgb = COLOR_MUTED + _add_field(fp, 'NUMPAGES') + + # ── Main converter ──────────────────────────────────────────────────── @@ -664,14 +950,23 @@ def convert_md_to_docx(md_file: str, docx_file: Optional[str] = None) -> str: if docx_file is None: docx_file = os.path.splitext(md_file)[0] + '.docx' + title = extract_title(raw) + meta, meta_skip = extract_metadata(raw) + doc = Document() core_props = doc.core_properties - core_props.title = os.path.splitext(os.path.basename(md_file))[0] - core_props.author = "iA by programmers.io" + core_props.title = title + core_props.author = meta.get('Author', 'iA by programmers.io') + core_props.subject = meta.get('Audience', 'Technical documentation') configure_document_defaults(doc) + setup_header_footer(doc, title, meta) + add_cover_page(doc, meta, title) + add_toc_page(doc) + enable_update_fields(doc) - i = 0 + section_count = 0 + i = meta_skip while i < len(lines): line = lines[i] stripped = line.strip() @@ -686,14 +981,19 @@ def convert_md_to_docx(md_file: str, docx_file: Optional[str] = None) -> str: m = re.match(r'^(#{1,6})\s+(.*)$', stripped) if m: level = len(m.group(1)) - title = strip_markdown_formatting(m.group(2)).strip() + heading_text = strip_markdown_formatting(m.group(2)).strip() # Trim trailing # tokens (ATX style) - title = re.sub(r'\s+#+\s*$', '', title) + heading_text = re.sub(r'\s+#+\s*$', '', heading_text) p = doc.add_heading(level=level) # Inline formatting allowed inside heading text - parse_inline_formatting(p, title) + parse_inline_formatting(p, heading_text) if level == 1: p.alignment = WD_ALIGN_PARAGRAPH.LEFT + if level == 2: + # Each major section starts on a fresh page (after the first) + section_count += 1 + if section_count >= 2: + p.paragraph_format.page_break_before = True i += 1 continue diff --git a/skills/ia/scripts/convert_md_to_pdf.py b/skills/ia/scripts/convert_md_to_pdf.py index 3eddcde..7e0cb6d 100644 --- a/skills/ia/scripts/convert_md_to_pdf.py +++ b/skills/ia/scripts/convert_md_to_pdf.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 """ Professional Markdown-to-PDF converter with comprehensive formatting support. -Produces polished technical documents suitable for distribution. +Produces polished technical documents suitable for distribution: +cover page, clickable table of contents, PDF outline bookmarks, +"Page N of M" footers, split-safe code blocks, accent-bar blockquotes. Usage: python convert_md_to_pdf.py input.md [output.pdf] @@ -9,23 +11,26 @@ """ import argparse +import datetime import os import re import sys from collections import OrderedDict -from typing import List, Optional, Tuple, Dict, Any +from typing import List, Optional, Tuple, Dict try: from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import cm, mm from reportlab.platypus import ( - SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, + Paragraph, Spacer, Table, TableStyle, HRFlowable, PageBreak, Flowable, NextPageTemplate, PageTemplate, BaseDocTemplate, Frame, ) + from reportlab.platypus.tableofcontents import TableOfContents + from reportlab.pdfbase.pdfmetrics import stringWidth from reportlab.lib import colors - from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT, TA_JUSTIFY + from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY except ImportError: print("Error: reportlab is not installed.") print("Install with: pip install reportlab") @@ -44,71 +49,129 @@ C_MUTED = colors.HexColor('#718096') C_BORDER = colors.HexColor('#cbd5e0') C_CODE_BG = colors.HexColor('#f7fafc') -C_ROW_ALT = colors.HexColor('#ebf4ff') +C_ROW_ALT = colors.HexColor('#eef5fc') C_RULE = colors.HexColor('#e2e8f0') C_COVER_BG = colors.HexColor('#1a365d') C_COVER_ACC = colors.HexColor('#2b6cb0') -C_INLINE_CODE = colors.HexColor('#c7254e') -C_CODE_TEXT = colors.HexColor('#2d3748') -C_BLOCKQUOTE = colors.HexColor('#4a5568') +C_CODE_TEXT = colors.HexColor('#2d3748') +C_BLOCKQUOTE = colors.HexColor('#44546a') +C_QUOTE_BG = colors.HexColor('#f0f5fa') +C_LINK = colors.HexColor('#2b6cb0') -# Unicode character replacements for PDF compatibility +BRAND = 'iA by programmers.io' + +# Unicode character replacements for PDF (WinAnsi font) compatibility CHAR_REPLACEMENTS = { - '\u26a0\ufe0f': '\u26a0', '\u2705': '\u2713', '\u274c': '\u2717', + '⚠️': '(!)', '⚠': '(!)', + '✅': '[x]', '❌': 'x', '\U0001f4c4': '[DOC]', '\U0001f4ca': '[CHART]', '\U0001f50d': '[FIND]', '\U0001f4cb': '[LIST]', '\U0001f4a1': '[TIP]', '\U0001f527': '[TOOL]', '\U0001f4cc': '[PIN]', '\U0001f3af': '[TARGET]', '\U0001f680': '[LAUNCH]', - '\u2013': '--', '\u2014': '---', '\u2018': "'", '\u2019': "'", - '\u201c': '"', '\u201d': '"', '\u2026': '...', '\u2190': '<-', '\u2192': '->', - '\u00d7': 'x', '\u2022': '•', '\u25cf': '●', '\u25cb': '○', - '\u2610': '☐', '\u2611': '☑', '\u2713': '✓', '\u2717': '✗', + '–': '-', '—': '--', '‘': "'", '’': "'", + '“': '"', '”': '"', '…': '...', '←': '<-', '→': '->', + '×': 'x', + '●': '•', '○': 'o', '◦': '·', + '▪': '·', '▫': '·', + '☐': '[ ]', '☑': '[x]', '✓': '+', '✗': 'x', + # Box-drawing characters -> ASCII so diagrams degrade gracefully + '─': '-', '│': '|', '┌': '+', '┐': '+', + '└': '+', '┘': '+', '├': '+', '┤': '+', + '┬': '+', '┴': '+', '┼': '+', + '═': '=', '║': '|', '╔': '+', '╗': '+', + '╚': '+', '╝': '+', } +# Characters >= 256 that the standard fonts CAN render (WinAnsi) +_SAFE_HIGH = {'•'} + + def sanitize_text(text: str) -> str: - """Replace characters that don't render well in standard PDF fonts.""" + """Replace characters that don't render in the standard PDF fonts.""" if not text: return text for bad, good in CHAR_REPLACEMENTS.items(): text = text.replace(bad, good) - return ''.join(ch if ord(ch) < 128 else '?' for ch in text) + return ''.join(ch if (ord(ch) < 256 or ch in _SAFE_HIGH) else '?' for ch in text) + + +def xml_escape(text: str) -> str: + return text.replace('&', '&').replace('<', '<').replace('>', '>') + + +# Markdown backslash-escapable punctuation +_MD_ESCAPE_RE = re.compile(r'\\([\\`*_{}\[\]()#+\-.!|<>])') + def inline_md_to_xml(text: str) -> str: - """Convert inline markdown formatting to reportlab XML tags.""" + """Convert inline markdown to reportlab XML markup (escaped & safe).""" if not text: return text text = sanitize_text(text) - - # Extract inline code spans first (protect from bold/italic regex) - code_spans = [] + + protected: List[str] = [] + + def keep(rendered: str) -> str: + protected.append(rendered) + return f'\x00P{len(protected) - 1}\x00' + + # 1. Inline code spans first (content is literal; escape it for XML) def save_code(m): - idx = len(code_spans) - content = m.group(1).replace('&', '&').replace('<', '<').replace('>', '>') - code_spans.append( - f'' - f' {content} ' + content = xml_escape(m.group(1)) + return keep( + f' {content} ' ) - return f'\x00C{idx}\x00' text = re.sub(r'`(.+?)`', save_code, text, flags=re.DOTALL) - - # Bold + italic, then bold, then italic + + # 2. Markdown backslash escapes -> literal character (protected) + text = _MD_ESCAPE_RE.sub(lambda m: keep(xml_escape(m.group(1))), text) + + # 3. XML-escape everything that remains + text = xml_escape(text) + + # Literal
written in markdown -> line break + text = re.sub(r'<br\s*/?>', '
', text, flags=re.IGNORECASE) + + # 4. Links (before bold/italic so URLs are untouched by those regexes) + def save_link(m): + label, url = m.group(1), m.group(2) + return keep(f'{label}') + text = re.sub(r'\[([^\]]+)\]\(([^)\s]+)\)', save_link, text) + + # 5. Bold + italic, then bold, then italic text = re.sub(r'\*\*\*(.+?)\*\*\*', r'\1', text) text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'(?\1', text) - # Links: [text](url) - text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) - # Checkboxes - text = re.sub(r'- \[x\] ', '\u2611 ', text, flags=re.IGNORECASE) - text = re.sub(r'- \[ \] ', '\u2610 ', text) - # Restore code spans - for idx, code in enumerate(code_spans): - text = text.replace(f'\x00C{idx}\x00', code) + + # Checkboxes (mid-text task lists) + text = re.sub(r'\[x\]', '[x]', text, flags=re.IGNORECASE) + + # 6. Restore protected spans + for idx, rendered in enumerate(protected): + text = text.replace(f'\x00P{idx}\x00', rendered) return text + # ── Table Parsing ──────────────────────────────────────────────────────── +def split_row(line: str) -> List[str]: + """Split a markdown table row, honoring escaped pipes and optional + leading/trailing pipes.""" + line = line.strip().replace('\\|', '\x00PIPE\x00') + if line.startswith('|'): + line = line[1:] + if line.endswith('|'): + line = line[:-1] + return [c.replace('\x00PIPE\x00', '\\|').strip() for c in line.split('|')] + + def is_table_separator(line: str) -> bool: line = line.strip() - return line.startswith('|') and line.endswith('|') and \ - all(re.match(r'^[\s\-:]+$', c) for c in [c.strip() for c in line.split('|')[1:-1]]) + if not line.startswith('|') and '|' not in line: + return False + cells = split_row(line) + return bool(cells) and all(re.match(r'^:?-{2,}:?$|^:-+:?$|^-+$|^[\s\-:]+$', c) for c in cells if c != '') \ + and any('-' in c for c in cells) + def parse_md_table(lines: List[str], start_idx: int) -> Tuple[List[List[str]], int]: """Parse markdown table starting at start_idx. Returns (rows, next_idx).""" @@ -116,17 +179,18 @@ def parse_md_table(lines: List[str], start_idx: int) -> Tuple[List[List[str]], i idx = start_idx while idx < len(lines): line = lines[idx].rstrip() - if not line.startswith('|'): + if not line.strip().startswith('|'): break if is_table_separator(line): idx += 1 continue - cells = [inline_md_to_xml(c.strip()) for c in line.split('|')[1:-1]] + cells = [inline_md_to_xml(c) for c in split_row(line)] if cells: rows.append(cells) idx += 1 return rows, idx + def calc_col_widths(rows: List[List[str]], avail_w: float) -> List[float]: if not rows: return [] @@ -152,30 +216,51 @@ def calc_col_widths(rows: List[List[str]], avail_w: float) -> List[float]: widths[j] = max(min_w, widths[j] - deficit / len(others)) return widths + # ── Style Definitions ──────────────────────────────────────────────────── +_STYLES = None + + def make_styles(): + global _STYLES + if _STYLES is not None: + return _STYLES base = getSampleStyleSheet() - return { - 'cover_title': ParagraphStyle('CoverTitle', fontSize=28, leading=34, textColor=colors.white, - fontName='Helvetica-Bold', alignment=TA_LEFT, spaceAfter=10), - 'cover_subtitle': ParagraphStyle('CoverSubtitle', fontSize=14, leading=18, - textColor=colors.HexColor('#bee3f8'), fontName='Helvetica', alignment=TA_LEFT, spaceAfter=20), + _STYLES = { + 'cover_eyebrow': ParagraphStyle('CoverEyebrow', fontSize=11, leading=15, + textColor=colors.HexColor('#90cdf4'), fontName='Helvetica-Bold', spaceAfter=8), + 'cover_title': ParagraphStyle('CoverTitle', fontSize=38, leading=44, textColor=colors.white, + fontName='Helvetica-Bold', alignment=TA_LEFT, spaceAfter=12), + 'cover_subtitle': ParagraphStyle('CoverSubtitle', fontSize=13, leading=18, + textColor=colors.HexColor('#bee3f8'), fontName='Helvetica', alignment=TA_LEFT, spaceAfter=18), 'cover_meta': ParagraphStyle('CoverMeta', fontSize=10, leading=16, textColor=colors.white, fontName='Helvetica', spaceAfter=4), + 'toc_title': ParagraphStyle('TOCTitle', fontSize=20, leading=26, + textColor=C_PRIMARY, fontName='Helvetica-Bold', spaceAfter=4, spaceBefore=8), + 'toc1': ParagraphStyle('TOCLevel1', fontSize=10.5, leading=15, textColor=C_PRIMARY, + fontName='Helvetica-Bold', leftIndent=0, firstLineIndent=0, spaceBefore=8), + 'toc2': ParagraphStyle('TOCLevel2', fontSize=9.5, leading=13, textColor=C_SECONDARY, + fontName='Helvetica', leftIndent=16, firstLineIndent=0, spaceBefore=3), 'h1': ParagraphStyle('H1', parent=base['Heading1'], fontSize=24, leading=30, - textColor=C_PRIMARY, fontName='Helvetica-Bold', spaceAfter=8, spaceBefore=30), + textColor=C_PRIMARY, fontName='Helvetica-Bold', spaceAfter=8, spaceBefore=30, + keepWithNext=1), 'h2': ParagraphStyle('H2', parent=base['Heading2'], fontSize=16, leading=22, - textColor=C_SECONDARY, fontName='Helvetica-Bold', spaceAfter=6, spaceBefore=22), + textColor=C_SECONDARY, fontName='Helvetica-Bold', spaceAfter=6, spaceBefore=22, + keepWithNext=1), 'h3': ParagraphStyle('H3', parent=base['Heading3'], fontSize=13, leading=18, - textColor=C_ACCENT, fontName='Helvetica-Bold', spaceAfter=4, spaceBefore=16), + textColor=C_ACCENT, fontName='Helvetica-Bold', spaceAfter=5, spaceBefore=16, + keepWithNext=1), 'h4': ParagraphStyle('H4', parent=base['Heading4'], fontSize=11, leading=15, - textColor=colors.HexColor('#4a5568'), fontName='Helvetica-Bold', spaceAfter=3, spaceBefore=12), + textColor=colors.HexColor('#4a5568'), fontName='Helvetica-Bold', spaceAfter=3, + spaceBefore=12, keepWithNext=1), 'h5': ParagraphStyle('H5', parent=base['Heading5'], fontSize=10, leading=14, - textColor=colors.HexColor('#718096'), fontName='Helvetica-Bold', spaceAfter=2, spaceBefore=10), + textColor=colors.HexColor('#718096'), fontName='Helvetica-Bold', spaceAfter=2, + spaceBefore=10, keepWithNext=1), 'h6': ParagraphStyle('H6', parent=base['Heading6'], fontSize=9, leading=12, - textColor=colors.HexColor('#a0aec0'), fontName='Helvetica-Bold', spaceAfter=2, spaceBefore=8), + textColor=colors.HexColor('#a0aec0'), fontName='Helvetica-Bold', spaceAfter=2, + spaceBefore=8, keepWithNext=1), 'body': ParagraphStyle('Body', parent=base['BodyText'], fontSize=10.5, leading=16, - alignment=TA_JUSTIFY, fontName='Helvetica', spaceAfter=6), + alignment=TA_JUSTIFY, fontName='Helvetica', spaceAfter=7), 'bullet': ParagraphStyle('Bullet', parent=base['BodyText'], fontSize=10.5, leading=16, fontName='Helvetica', leftIndent=22, firstLineIndent=-10, spaceAfter=3, spaceBefore=1), 'bullet2': ParagraphStyle('Bullet2', parent=base['BodyText'], fontSize=10.5, leading=16, @@ -188,66 +273,143 @@ def make_styles(): fontName='Helvetica', leftIndent=48, firstLineIndent=-14, spaceAfter=3, spaceBefore=1), 'number3': ParagraphStyle('Number3', parent=base['BodyText'], fontSize=10.5, leading=16, fontName='Helvetica', leftIndent=70, firstLineIndent=-14, spaceAfter=3, spaceBefore=1), - 'code': ParagraphStyle('CodeBlock', parent=base['Code'], fontSize=8.5, leading=12, - fontName='Courier', backColor=C_CODE_BG, leftIndent=10, rightIndent=10, - spaceAfter=10, spaceBefore=8, borderPadding=8, borderColor=C_BORDER, borderWidth=0.5), - 'quote': ParagraphStyle('Quote', parent=base['BodyText'], fontSize=10, leading=15, - fontName='Helvetica-Oblique', leftIndent=24, rightIndent=24, textColor=C_BLOCKQUOTE, - spaceAfter=8, spaceBefore=8), - 'table_cell': ParagraphStyle('TCell', parent=base['Normal'], fontSize=9, leading=12.5, fontName='Helvetica'), + 'quote': ParagraphStyle('Quote', parent=base['BodyText'], fontSize=9.8, leading=14.5, + fontName='Helvetica', textColor=C_BLOCKQUOTE, spaceAfter=0, spaceBefore=0), + 'table_cell': ParagraphStyle('TCell', parent=base['Normal'], fontSize=9, leading=12.5, + fontName='Helvetica', wordWrap='CJK'), 'table_header': ParagraphStyle('THead', parent=base['Normal'], fontSize=9.5, leading=13, - fontName='Helvetica-Bold', textColor=colors.white, alignment=TA_CENTER), + fontName='Helvetica-Bold', textColor=colors.white, alignment=TA_LEFT), } + return _STYLES + # ── Custom Flowables ───────────────────────────────────────────────────── class SectionRule(HRFlowable): def __init__(self, color=C_RULE, thickness=1.5, width='100%'): - HRFlowable.__init__(self, width=width, thickness=thickness, color=color, spaceAfter=8, spaceBefore=4) + HRFlowable.__init__(self, width=width, thickness=thickness, color=color, + spaceAfter=8, spaceBefore=4) + class ThinRule(HRFlowable): def __init__(self): - HRFlowable.__init__(self, width='100%', thickness=0.5, color=C_RULE, spaceAfter=10, spaceBefore=10) + HRFlowable.__init__(self, width='100%', thickness=0.5, color=C_RULE, + spaceAfter=10, spaceBefore=10) + class CodeBlockFlowable(Flowable): - def __init__(self, code_text: str, language: str = '', width: float = CONTENT_W): + """Code block with language chip, auto font-fit, soft wrap of long + lines, and page-split support.""" + PAD_X = 12 + PAD_TOP = 10 + PAD_BOT = 10 + + def __init__(self, code_text: str, language: str = '', _font_size: Optional[float] = None): Flowable.__init__(self) - self.code_text = code_text or '' + self.raw_lines = [sanitize_text(l.replace('\t', ' ')).rstrip() + for l in (code_text or '').split('\n')] self.language = language - self.width = width + self._forced_size = _font_size + self.font_size = _font_size or 9.0 + self.rows: List[str] = [] + + def _header_h(self) -> float: + return 16 if self.language else 0 + + def _layout(self, avail_w: float): + usable = avail_w - 2 * self.PAD_X + size = self._forced_size or 9.0 + if not self._forced_size: + longest = max((stringWidth(l, 'Courier', 10) for l in self.raw_lines), default=0) / 10.0 + if longest > 0: + size = min(9.0, usable / longest) + size = max(size, 6.5) + self.font_size = size + self.line_h = size + 3.0 + char_w = stringWidth('M', 'Courier', size) + max_chars = max(int(usable / char_w), 8) + rows: List[str] = [] + for line in self.raw_lines: + if stringWidth(line, 'Courier', size) <= usable: + rows.append(line) + continue + # Hard-wrap an over-long line; continuation indented two spaces + rest = line + first = True + while stringWidth(rest, 'Courier', size) > usable and len(rest) > max_chars: + cut = max_chars if first else max_chars - 2 + rows.append((rest[:cut]) if first else (' ' + rest[:cut])) + rest = rest[cut:] + first = False + rows.append(rest if first else ' ' + rest) + self.rows = rows + def wrap(self, availWidth, availHeight): - self.width = min(self.width, availWidth) - lines = self.code_text.split('\n') - self.rows = [line.replace('\t', ' ')[:120] for line in lines] - line_h = 11 - header_h = 20 if self.language else 0 - self.height = header_h + len(self.rows) * line_h + 20 + self.width = availWidth + self._layout(availWidth) + self.height = self._header_h() + self.PAD_TOP + self.PAD_BOT + len(self.rows) * self.line_h return (self.width, self.height) + + def split(self, availWidth, availHeight): + self.wrap(availWidth, availHeight) + if self.height <= availHeight: + return [self] + budget = availHeight - self._header_h() - self.PAD_TOP - self.PAD_BOT + fit_rows = int(budget // self.line_h) + if fit_rows < 4 or fit_rows >= len(self.rows): + return [] # move whole block to the next page + head = CodeBlockFlowable('\n'.join(self.rows[:fit_rows]), self.language, + _font_size=self.font_size) + cont_lang = (self.language + ' (cont.)') if self.language else '' + tail = CodeBlockFlowable('\n'.join(self.rows[fit_rows:]), cont_lang, + _font_size=self.font_size) + return [head, tail] + def draw(self): c = self.canv w, h = self.width, self.height - line_h = 11 - header_h = 20 if self.language else 0 - pad = 12 + header_h = self._header_h() c.setFillColor(C_CODE_BG) c.setStrokeColor(C_BORDER) c.setLineWidth(0.5) c.roundRect(0, 0, w, h, 4, fill=1, stroke=1) + # Left accent bar + c.setFillColor(C_ACCENT) + c.rect(0, 0, 2.2, h, fill=1, stroke=0) if self.language: - c.setFillColor(C_BORDER) - c.roundRect(0, h - header_h, w, header_h, 4, fill=1, stroke=0) - c.setFillColor(C_BORDER) - c.rect(0, h - header_h, w, 4, fill=1, stroke=0) - c.setFillColor(C_MUTED) - c.setFont('Helvetica-Bold', 8) - c.drawString(pad, h - header_h + 6, self.language.upper()) + c.setFillColor(colors.HexColor('#e6edf5')) + c.rect(2.2, h - header_h, w - 2.2, header_h, fill=1, stroke=0) + c.setFillColor(C_SECONDARY) + c.setFont('Helvetica-Bold', 7.5) + c.drawString(self.PAD_X, h - header_h + 5, self.language.upper()) c.setFillColor(C_CODE_TEXT) - c.setFont('Courier', 8.5) - y = h - header_h - 12 + c.setFont('Courier', self.font_size) + y = h - header_h - self.PAD_TOP - self.font_size + 2 for row in self.rows: - c.drawString(pad, y, row) - y -= line_h + c.drawString(self.PAD_X, y, row) + y -= self.line_h + + +def make_quote(xml_text: str, styles) -> Table: + """Blockquote rendered as accent-bar callout.""" + p = Paragraph(xml_text, styles['quote']) + t = Table([[p]], colWidths=[CONTENT_W - 4]) + t.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), C_QUOTE_BG), + ('LINEBEFORE', (0, 0), (0, -1), 3, C_ACCENT), + ('LEFTPADDING', (0, 0), (-1, -1), 12), + ('RIGHTPADDING', (0, 0), (-1, -1), 10), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + return t + # ── Page Templates ─────────────────────────────────────────────────────── +# NOTE: a deferred-emission "NumberedCanvas" must NOT be used here — it breaks +# bookmarkPage/addOutlineEntry (every named destination binds to page 1, so +# TOC links and outline bookmarks all jump to the cover). The page total is +# taken from the previous multiBuild pass instead, which is stable once the +# table of contents has converged. class DocTemplate(BaseDocTemplate): def __init__(self, filename, **kwargs): super().__init__(filename, **kwargs) @@ -257,18 +419,53 @@ def __init__(self, filename, **kwargs): body_tpl = PageTemplate(id='Body', frames=[frame_body], onPage=self._draw_body) self.addPageTemplates([cover_tpl, body_tpl]) self._doc_title = '' - self._cover_offset = 0 + self._footer_left = BRAND + self._toc_seq = 0 + self._this_pass_total = 0 + self._prev_pass_total = 0 + def set_title(self, title): self._doc_title = title - def set_cover_offset(self, n): - self._cover_offset = n + + def set_footer_left(self, text): + self._footer_left = text + + # Reset per build pass (multiBuild runs several passes) + def beforeDocument(self): + self._toc_seq = 0 + self._prev_pass_total = self._this_pass_total + self._this_pass_total = 0 + + def afterPage(self): + self._this_pass_total = self.page + + def afterFlowable(self, flowable): + """Register H2/H3 headings: TOC entries + PDF outline bookmarks.""" + if not isinstance(flowable, Paragraph): + return + sname = flowable.style.name + if sname not in ('H2', 'H3'): + return + text = flowable.getPlainText() + key = f'toc-{self._toc_seq}' + self._toc_seq += 1 + level = 0 if sname == 'H2' else 1 + self.canv.bookmarkPage(key) + try: + self.canv.addOutlineEntry(text, key, level=level, closed=False) + except Exception: + pass # outline nesting can fail if an H3 precedes any H2 + self.notify('TOCEntry', (level, text, self.page - 1, key)) + def _draw_cover(self, canvas, doc): c = canvas c.saveState() c.setFillColor(C_COVER_BG) c.rect(0, 0, PAGE_W, PAGE_H, fill=1, stroke=0) c.setFillColor(C_COVER_ACC) - c.rect(0, 0, 10 * mm, PAGE_H, fill=1, stroke=0) + c.rect(0, 0, 8 * mm, PAGE_H, fill=1, stroke=0) + c.setFillColor(colors.HexColor('#3d6a9e')) + c.rect(8 * mm, 0, 1.2, PAGE_H, fill=1, stroke=0) c.setFillColor(colors.HexColor('#2a4365')) c.circle(PAGE_W - 3 * cm, PAGE_H - 3 * cm, 5 * cm, fill=1, stroke=0) c.setFillColor(colors.HexColor('#23415c')) @@ -277,61 +474,79 @@ def _draw_cover(self, canvas, doc): c.setLineWidth(1.5) c.line(MARGIN, 2.5 * cm, PAGE_W - MARGIN, 2.5 * cm) c.setFont('Helvetica', 7.5) - c.setFillColor(C_MUTED) - c.drawString(MARGIN, 2 * cm, "Generated by iA from programmers.io") + c.setFillColor(colors.HexColor('#8aa6c4')) + c.drawString(MARGIN, 2 * cm, f"Generated by {BRAND}") c.drawRightString(PAGE_W - MARGIN, 2 * cm, "Confidential") c.restoreState() + def _draw_body(self, canvas, doc): c = canvas c.saveState() + # Header: accent rule + running title c.setStrokeColor(C_ACCENT) c.setLineWidth(1.5) c.line(MARGIN, PAGE_H - MARGIN + 14, PAGE_W - MARGIN, PAGE_H - MARGIN + 14) if self._doc_title: c.setFont('Helvetica-Bold', 8) c.setFillColor(C_MUTED) - c.drawString(MARGIN, PAGE_H - MARGIN + 18, self._doc_title[:80]) + c.drawString(MARGIN, PAGE_H - MARGIN + 18, sanitize_text(self._doc_title)[:90]) + # Footer: thin rule + brand/date (page number drawn by NumberedCanvas) c.setStrokeColor(C_BORDER) c.setLineWidth(0.5) c.line(MARGIN, MARGIN - 6, PAGE_W - MARGIN, MARGIN - 6) - page_num = doc.page - self._cover_offset + c.setFont('Helvetica', 8) + c.setFillColor(C_MUTED) + c.drawString(MARGIN, MARGIN - 16, sanitize_text(self._footer_left)[:90]) + page_num = doc.page - 1 if page_num > 0: - c.setFont('Helvetica', 8) - c.setFillColor(C_MUTED) - c.drawRightString(PAGE_W - MARGIN, MARGIN - 16, f"Page {page_num}") + total = self._prev_pass_total - 1 + label = f"Page {page_num} of {total}" if total >= page_num else f"Page {page_num}" + c.drawRightString(PAGE_W - MARGIN, MARGIN - 16, label) c.restoreState() + # ── Metadata Extraction ────────────────────────────────────────────────── def extract_metadata(md_content: str) -> Tuple[OrderedDict, int]: + """Parse the leading '**Key:** value' header block (after the H1 title). + + Handles several pairs on one line separated by '|', e.g. + '**Library:** KUNALP | **Source file:** QCLSRC | **Member type:** CLLE'. + Returns (meta, index_of_first_body_line). + """ meta = OrderedDict() lines = md_content.split('\n') in_header = False skip_count = 0 - for i, line in enumerate(lines): - line = line.strip() + for i, raw in enumerate(lines): + line = raw.strip() if not line: if in_header and meta: skip_count = i + 1 break continue - if re.match(r'\*\*[^*]+\*\*:', line): + if line.startswith('#'): + if in_header: + skip_count = i + break + continue # the H1 title precedes the metadata block + if re.match(r'\*\*[^*]+?:?\*\*:?\s*\S', line): in_header = True - m = re.match(r'\*\*([^*]+)\*\*:\s*(.+)', line) - if m: - meta[m.group(1).strip()] = m.group(2).strip() - skip_count = i + 1 + for seg in re.split(r'\s*\|\s*(?=\*\*)', line): + m = re.match(r'\*\*([^*]+?):?\*\*:?\s*(.+)$', seg.strip()) + if m: + meta[m.group(1).strip()] = m.group(2).strip() + skip_count = i + 1 elif in_header and line == '---': skip_count = i + 1 break - elif in_header and not line.startswith('**') and meta: + elif in_header: skip_count = i break - elif in_header: - skip_count = i + 1 else: break return meta, skip_count + def extract_title(md_content: str) -> str: for line in md_content.split('\n'): line = line.strip() @@ -339,13 +554,16 @@ def extract_title(md_content: str) -> str: return line[2:].strip() return 'Technical Specification' + # ── List Parsing ───────────────────────────────────────────────────────── +_LIST_ITEM_RE = re.compile(r'^\s*([-*+]|\d+\.)\s') + + def parse_list(lines: List[str], start_idx: int, ordered: bool) -> Tuple[List[Dict], int]: items = [] idx = start_idx while idx < len(lines): line = lines[idx] - stripped = line.strip() indent = len(line) - len(line.lstrip()) indent_level = min(indent // 2, 2) if ordered: @@ -358,19 +576,22 @@ def parse_list(lines: List[str], start_idx: int, ordered: bool) -> Tuple[List[Di if not m: break items.append({'level': indent_level, 'text': m.group(3)}) - # Handle multi-line list items - while idx + 1 < len(lines) and (lines[idx + 1].startswith(' ') or lines[idx + 1].startswith('\t')): + # Multi-line list items: absorb indented continuation lines, but + # never absorb lines that are themselves list items (nested items). + while idx + 1 < len(lines) and lines[idx + 1].strip() \ + and (lines[idx + 1].startswith(' ') or lines[idx + 1].startswith('\t')) \ + and not _LIST_ITEM_RE.match(lines[idx + 1]): idx += 1 items[-1]['text'] += ' ' + lines[idx].strip() idx += 1 return items, idx + # ── Markdown to PDF Elements ───────────────────────────────────────────── def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: styles = make_styles() elements = [] lines = md_content.split('\n') - i = 0 section_count = 0 _, meta_skip = extract_metadata(md_content) i = meta_skip @@ -391,7 +612,6 @@ def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: i += 1 elif line.startswith('### '): elements.append(Paragraph(inline_md_to_xml(line[4:].strip()), styles['h3'])) - elements.append(Spacer(1, 2)) i += 1 elif line.startswith('## '): section_count += 1 @@ -419,7 +639,9 @@ def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: i += 1 i += 1 if code_lines: + elements.append(Spacer(1, 4)) elements.append(CodeBlockFlowable('\n'.join(code_lines), lang)) + elements.append(Spacer(1, 6)) # Table elif line.strip().startswith('|'): rows, new_idx = parse_md_table(lines, i) @@ -428,7 +650,7 @@ def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: table = create_table(rows) if table: elements.append(table) - elements.append(Spacer(1, 4)) + elements.append(Spacer(1, 6)) i = new_idx continue # Blockquote @@ -438,7 +660,9 @@ def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: quote_lines.append(inline_md_to_xml(lines[i].strip()[1:].strip())) i += 1 if quote_lines: - elements.append(Paragraph('
'.join(quote_lines), styles['quote'])) + elements.append(Spacer(1, 4)) + elements.append(make_quote('
'.join(q for q in quote_lines if q), styles)) + elements.append(Spacer(1, 6)) # Unordered List elif re.match(r'^[\s]*[-*+] ', line): list_items, new_idx = parse_list(lines, i, ordered=False) @@ -448,9 +672,9 @@ def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: if level == 0: elements.append(Paragraph(f'• {text}', styles['bullet'])) elif level == 1: - elements.append(Paragraph(f' ◦ {text}', styles['bullet2'])) + elements.append(Paragraph(f'- {text}', styles['bullet2'])) else: - elements.append(Paragraph(f' ▪ {text}', styles['bullet3'])) + elements.append(Paragraph(f'· {text}', styles['bullet3'])) i = new_idx continue # Ordered List @@ -463,9 +687,9 @@ def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: if level == 0: elements.append(Paragraph(f'{num}. {text}', styles['number'])) elif level == 1: - elements.append(Paragraph(f' {num}. {text}', styles['number2'])) + elements.append(Paragraph(f'{num}. {text}', styles['number2'])) else: - elements.append(Paragraph(f' {num}. {text}', styles['number3'])) + elements.append(Paragraph(f'{num}. {text}', styles['number3'])) i = new_idx continue # Regular Paragraph @@ -485,55 +709,138 @@ def md_to_pdf_elements(md_content: str, doc_title: str = '') -> List[Flowable]: para_text = ' '.join(para_lines) elements.append(Paragraph(inline_md_to_xml(para_text), styles['body'])) continue - i += 1 return elements + +_NUMERIC_RE = re.compile(r'^-?\d+(\.\d+)?%?$') + + def create_table(rows: List[List[str]]) -> Optional[Table]: if not rows: return None + styles = make_styles() max_cols = max(len(row) for row in rows) for row in rows: while len(row) < max_cols: row.append('') col_widths = calc_col_widths(rows, CONTENT_W) data = [] + numeric_cells = [] for row_idx, row in enumerate(rows): row_data = [] for col_idx, cell in enumerate(row): if row_idx == 0: - row_data.append(Paragraph(f'{cell}', make_styles()['table_header'])) + row_data.append(Paragraph(f'{cell}', styles['table_header'])) else: - row_data.append(Paragraph(cell, make_styles()['table_cell'])) + plain = re.sub(r'<[^>]+>', '', cell).replace(' ', '').strip() + if plain and _NUMERIC_RE.match(plain): + numeric_cells.append((col_idx, row_idx)) + row_data.append(Paragraph(cell, styles['table_cell'])) data.append(row_data) if not data: return None - table = Table(data, colWidths=col_widths, repeatRows=1) + table = Table(data, colWidths=col_widths, repeatRows=1, splitByRow=1) + # Clean "report" style: header band, horizontal rules only, banded rows style = [ - ('BACKGROUND', (0,0), (-1,0), C_TABLE_HDR), - ('TEXTCOLOR', (0,0), (-1,0), colors.white), - ('ALIGN', (0,0), (-1,0), 'CENTER'), - ('VALIGN', (0,0), (-1,-1), 'MIDDLE'), - ('FONTNAME', (0,0), (-1,0), 'Helvetica-Bold'), - ('FONTSIZE', (0,0), (-1,0), 9.5), - ('BOTTOMPADDING', (0,0), (-1,0), 8), - ('TOPPADDING', (0,0), (-1,0), 8), - ('FONTNAME', (0,1), (-1,-1), 'Helvetica'), - ('FONTSIZE', (0,1), (-1,-1), 9), - ('BOTTOMPADDING', (0,1), (-1,-1), 6), - ('TOPPADDING', (0,1), (-1,-1), 6), - ('LEFTPADDING', (0,0), (-1,-1), 6), - ('RIGHTPADDING', (0,0), (-1,-1), 6), - ('GRID', (0,0), (-1,-1), 0.5, C_BORDER), - ('LINEBELOW', (0,0), (-1,0), 1.5, C_TABLE_HDR), + ('BACKGROUND', (0, 0), (-1, 0), C_TABLE_HDR), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('VALIGN', (0, 0), (-1, 0), 'MIDDLE'), + ('VALIGN', (0, 1), (-1, -1), 'TOP'), + ('TOPPADDING', (0, 0), (-1, 0), 7), + ('BOTTOMPADDING', (0, 0), (-1, 0), 7), + ('TOPPADDING', (0, 1), (-1, -1), 5), + ('BOTTOMPADDING', (0, 1), (-1, -1), 5), + ('LEFTPADDING', (0, 0), (-1, -1), 7), + ('RIGHTPADDING', (0, 0), (-1, -1), 7), + ('LINEABOVE', (0, 0), (-1, 0), 1.0, C_PRIMARY), + ('LINEBELOW', (0, 0), (-1, 0), 1.2, C_PRIMARY), + ('LINEBELOW', (0, -1), (-1, -1), 0.9, C_BORDER), ] for row_idx in range(1, len(data)): + if row_idx < len(data) - 1: + style.append(('LINEBELOW', (0, row_idx), (-1, row_idx), 0.4, C_RULE)) if row_idx % 2 == 0: - style.append(('BACKGROUND', (0,row_idx), (-1,row_idx), C_ROW_ALT)) + style.append(('BACKGROUND', (0, row_idx), (-1, row_idx), C_ROW_ALT)) + for col_idx, row_idx in numeric_cells: + style.append(('ALIGN', (col_idx, row_idx), (col_idx, row_idx), 'CENTER')) table.setStyle(TableStyle(style)) return table + +# ── Cover & TOC ───────────────────────────────────────────────────────── +_SUBTITLE_KEYS = ('Member type', 'Type', 'Program', 'Source file', 'File', + 'Library version documented', 'Library') + + +def build_cover(meta: OrderedDict, title_text: str, styles) -> List[Flowable]: + # Split "Technical Specification — IADEPRPT" into eyebrow + main title + eyebrow, main = 'TECHNICAL DOCUMENT', title_text + for dash in ('—', ' -- ', ' - '): + if dash in title_text: + left, right = title_text.split(dash, 1) + if left.strip() and right.strip(): + eyebrow, main = left.strip().upper(), right.strip() + break + main = sanitize_text(main) + eyebrow = sanitize_text(eyebrow) + title_style = styles['cover_title'] + if len(main) > 18: + title_style = ParagraphStyle('CoverTitleSmall', parent=title_style, + fontSize=28 if len(main) <= 30 else 22, + leading=34 if len(main) <= 30 else 28) + elements = [Spacer(1, 3.4 * cm)] + elements.append(Paragraph(eyebrow, styles['cover_eyebrow'])) + elements.append(Paragraph(main, title_style)) + parts = [] + for key in ('Member type', 'Type', 'Program'): + if key in meta: + parts.append(meta[key]) + break + for key in ('Source file', 'File'): + if key in meta: + parts.append(f"Source: {meta[key]}") + break + for key in ('Library version documented', 'Library'): + if key in meta: + parts.append(f"Library: {meta[key]}") + break + subtitle = ' | '.join(sanitize_text(p) for p in parts) if parts else '' + if subtitle: + elements.append(Paragraph(subtitle, styles['cover_subtitle'])) + elements.append(Spacer(1, 0.4 * cm)) + elements.append(HRFlowable(width='35%', thickness=1.2, color=colors.HexColor('#4a9fd8'), + spaceAfter=20, spaceBefore=6, hAlign='LEFT')) + shown = False + for key, value in meta.items(): + if key in _SUBTITLE_KEYS: + continue + elements.append(Paragraph( + f'{xml_escape(key)}: ' + f'{xml_escape(sanitize_text(value))}', + styles['cover_meta'])) + elements.append(Spacer(1, 0.15 * cm)) + shown = True + if 'Date' not in meta: + today = datetime.date.today().isoformat() + elements.append(Paragraph( + f'Date: ' + f'{today}', styles['cover_meta'])) + return elements + + +def build_toc(styles) -> List[Flowable]: + toc = TableOfContents() + toc.levelStyles = [styles['toc1'], styles['toc2']] + toc.dotsMinLevel = 0 + return [ + Paragraph('Contents', styles['toc_title']), + HRFlowable(width='100%', thickness=1.2, color=C_RULE, spaceAfter=14, spaceBefore=4), + toc, + ] + + # ── Main Conversion ───────────────────────────────────────────────────── -def convert_md_to_pdf(md_file: str, pdf_file: str = None) -> str: +def convert_md_to_pdf(md_file: str, pdf_file: Optional[str] = None) -> str: try: with open(md_file, 'r', encoding='utf-8') as f: md_content = f.read() @@ -547,50 +854,27 @@ def convert_md_to_pdf(md_file: str, pdf_file: str = None) -> str: pdf_file = os.path.splitext(md_file)[0] + '.pdf' title = extract_title(md_content) meta, _ = extract_metadata(md_content) + styles = make_styles() doc = DocTemplate(pdf_file, pagesize=A4, rightMargin=MARGIN, leftMargin=MARGIN, - topMargin=MARGIN, bottomMargin=MARGIN + 10) + topMargin=MARGIN, bottomMargin=MARGIN + 10, + title=sanitize_text(title), + author=meta.get('Author', BRAND), + subject=meta.get('Audience', 'Technical documentation'), + creator=f'{BRAND} markdown converter') doc.set_title(title) - doc.set_cover_offset(1) + date_str = meta.get('Date', datetime.date.today().isoformat()) + doc.set_footer_left(f'{BRAND} | {date_str}') elements = [] - elements.append(NextPageTemplate('Cover')) - elements.append(PageBreak()) - elements.extend(build_cover(meta, title, make_styles())) + elements.extend(build_cover(meta, title, styles)) elements.append(NextPageTemplate('Body')) elements.append(PageBreak()) + elements.extend(build_toc(styles)) + elements.append(PageBreak()) elements.extend(md_to_pdf_elements(md_content, title)) - doc.build(elements) + doc.multiBuild(elements) return pdf_file -def build_cover(meta: OrderedDict, title_text: str, styles: dict) -> List[Flowable]: - elements = [Spacer(1, 4 * cm)] - elements.append(Paragraph(title_text, styles['cover_title'])) - elements.append(Spacer(1, 0.4 * cm)) - parts = [] - for key in ['Member type', 'Type', 'Program']: - if key in meta: - parts.append(meta[key]) - break - for key in ['Source file', 'File']: - if key in meta: - parts.append(f"Source: {meta[key]}") - break - for key in ['Library version documented', 'Library']: - if key in meta: - parts.append(f"Library: {meta[key]}") - break - subtitle = ' | '.join(parts) if parts else 'Technical Specification' - elements.append(Paragraph(subtitle, styles['cover_subtitle'])) - elements.append(Spacer(1, 0.5 * cm)) - elements.append(HRFlowable(width='35%', thickness=1.2, color=colors.HexColor('#4a9fd8'), - spaceAfter=20, spaceBefore=6, hAlign='LEFT')) - for key in ['Author', 'Date', 'Audience', 'Version', 'Status']: - if key in meta: - elements.append(Paragraph( - f'{key}: ' - f'{meta[key]}', styles['cover_meta'])) - elements.append(Spacer(1, 0.15 * cm)) - return elements # ── CLI ────────────────────────────────────────────────────────────────── def main(): @@ -602,7 +886,8 @@ def main(): print(f"Error: Input file '{args.input}' not found.") sys.exit(1) output_file = convert_md_to_pdf(args.input, args.output) - print(f"✓ Successfully created: {output_file}") + print(f"Successfully created: {output_file}") + if __name__ == '__main__': main() diff --git a/skills/ia/scripts/validate_flowchart.py b/skills/ia/scripts/validate_flowchart.py new file mode 100644 index 0000000..94f4ad5 --- /dev/null +++ b/skills/ia/scripts/validate_flowchart.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Lint a generated flowchart HTML (Gate 1 of flowchart.md §6). Pure stdlib. + +Usage: python validate_flowchart.py +Exit 0 = pass, 1 = findings (printed one per line). + +Checks: + - exactly one
block + - diagram starts with `flowchart TD` (never `graph TD`) + - no `subgraph` + - no reserved node id `end` + - every declared node has a `class` assignment; every assignment uses a + defined `classDef` and references a declared node + - Mermaid pinned at 10.9.0 + - no bare `&` inside the Mermaid block (must be `&`) + - no leftover template placeholders anywhere in the file +""" +import re +import sys + +KNOWN_PLACEHOLDER_RX = [ + re.compile(r"\[[A-Z0-9_]+(?:[ ·/+.-][A-Z0-9_/.-]+)*\]"), # [PROGRAM_NAME], [NNN], [N PF + N DSPF], [YYYY-MM-DD] + re.compile(r"L\[x"), # line-ref stubs L[x], L[x-y] +] +KNOWN_PLACEHOLDER_LITERALS = [ + "[PLACEHOLDER]", "[one-line", "[One-line", "[node label", "[opt 1]", "[opt 2]", + "[Main/Display/Add", "[*ENTRY/PI params", "[CALLERS or none]", "[CALLEES or none]", + "only keys this program handles", "[Two or three sentences", "✏", # ✏️ template instruction marker +] + + +def main(path): + findings = [] + try: + with open(path, encoding="utf-8") as f: + html = f.read() + except OSError as e: + print(f"FAIL: cannot read {path}: {e}") + return 1 + + # -- one mermaid block -------------------------------------------------- + blocks = re.findall(r'
(.*?)
', html, re.DOTALL) + if len(blocks) != 1: + findings.append(f"expected exactly 1 mermaid block, found {len(blocks)}" + " (multi-tab layouts are not the lean standard)") + mermaid = blocks[0] if blocks else "" + + # strip %% comment lines and quoted labels for structural scans + code_lines = [l for l in mermaid.splitlines() if not l.strip().startswith("%%")] + code = "\n".join(code_lines) + code_nq = re.sub(r'"[^"]*"', '""', code) + + # -- header ------------------------------------------------------------- + first = next((l.strip() for l in code_lines if l.strip()), "") + if mermaid and first != "flowchart TD": + findings.append(f"diagram must start with 'flowchart TD', found: '{first}'") + if re.search(r"\bgraph\s+(TD|LR|RL|BT)\b", code_nq): + findings.append("uses 'graph ...' — must be 'flowchart TD'") + if re.search(r"\bsubgraph\b", code_nq): + findings.append("contains 'subgraph' — breaks top-to-bottom layout, use one flat graph") + + # -- reserved id -------------------------------------------------------- + if re.search(r"(? +Exit 0 = pass, 1 = findings (printed one per line). + +Checks: + - TC ids match TC-{PGM}-NNN, share one program token, unique, sequential from 001 + - every TC block is complete: field table (Category / Priority / Traces to / + Preconditions / Test data), numbered Steps list, Expected result, + execution-strip table + - execution strips are blank (Actual result / Status / Tester / Date) + - traceability matrix is two-way consistent: every "Traces to" BR-xxx has a + matrix row, every TC id in the matrix exists as a block, every matrix BR + is covered by at least one TC + - no leftover template placeholders ( tokens, TBD, TODO) +""" +import re +import sys + +TC_HEAD_RX = re.compile(r"^### (TC-[A-Z0-9]+-\d{3})\s+—\s+\S") +TC_ID_RX = re.compile(r"TC-([A-Z0-9]+)-(\d{3})") +BR_RX = re.compile(r"BR-\d{3}") +PLACEHOLDER_RX = re.compile(r"<[A-Z][A-Za-z0-9_ /,.+'-]*>") +FIELD_ROWS = ["Category", "Priority", "Traces to", "Preconditions", "Test data"] +EXEC_HEADER = re.compile(r"^\|\s*Actual result\s*\|\s*Status\s*\|\s*Tester\s*\|\s*Date\s*\|") + + +def split_tc_blocks(lines): + """Return [(tc_id, heading_line_no, [block lines])] for every ### TC- heading.""" + blocks, current = [], None + for n, line in enumerate(lines, 1): + m = TC_HEAD_RX.match(line) + if m: + current = (m.group(1), n, []) + blocks.append(current) + elif line.startswith("### ") or line.startswith("## "): + current = None + elif current: + current[2].append(line) + return blocks + + +def main(path): + findings = [] + try: + with open(path, encoding="utf-8") as f: + text = f.read() + except OSError as e: + print(f"FAIL: cannot read {path}: {e}") + return 1 + lines = text.splitlines() + + # -- TC blocks ------------------------------------------------------------ + blocks = split_tc_blocks(lines) + if not blocks: + findings.append("no test case blocks found (headings must be '### TC-{PGM}-NNN — title')") + + # malformed TC headings that didn't match the strict heading regex + for n, line in enumerate(lines, 1): + if line.startswith("### TC-") and not TC_HEAD_RX.match(line): + findings.append(f"malformed TC heading at line {n}: '{line.strip()}'" + " (expected '### TC-{PGM}-NNN — title')") + + # -- id format / program token / uniqueness / sequence --------------------- + pgm_tokens = {TC_ID_RX.search(tc_id).group(1) for tc_id, _, _ in blocks} + if len(pgm_tokens) > 1: + findings.append(f"TC ids use multiple program tokens: {sorted(pgm_tokens)}") + seen, numbers = set(), [] + for tc_id, n, _ in blocks: + if tc_id in seen: + findings.append(f"duplicate TC id '{tc_id}' (line {n})") + seen.add(tc_id) + numbers.append(int(TC_ID_RX.search(tc_id).group(2))) + if numbers: + expected = list(range(1, len(numbers) + 1)) + if sorted(numbers) != expected: + findings.append(f"TC numbering not sequential from 001: found {sorted(numbers)}") + elif numbers != expected: + findings.append("TC blocks out of order: ids must appear in ascending sequence") + + # -- per-block completeness ------------------------------------------------- + traced_brs = set() + for tc_id, n, body in blocks: + body_text = "\n".join(body) + for row in FIELD_ROWS: + if not re.search(r"^\|\s*" + re.escape(row) + r"\s*\|", body_text, re.MULTILINE): + findings.append(f"{tc_id}: field table missing '{row}' row") + if not re.search(r"^\*\*Steps\*\*", body_text, re.MULTILINE): + findings.append(f"{tc_id}: missing '**Steps**' block") + elif not re.search(r"^1\.\s+\S", body_text, re.MULTILINE): + findings.append(f"{tc_id}: Steps has no numbered step '1. ...'") + if not re.search(r"^\*\*Expected result\*\*", body_text, re.MULTILINE): + findings.append(f"{tc_id}: missing '**Expected result**' block") + # execution strip: header row then blank data row + strip_ok = False + for i, line in enumerate(body): + if EXEC_HEADER.match(line.strip()): + data = body[i + 2] if i + 2 < len(body) else "" + cells = [c.strip() for c in data.strip().strip("|").split("|")] + if len(cells) == 4 and not any(cells): + strip_ok = True + elif len(cells) == 4: + findings.append(f"{tc_id}: execution strip is not blank ({data.strip()})") + strip_ok = True # present but pre-filled — already reported + break + if not strip_ok: + findings.append(f"{tc_id}: missing execution strip table" + " (| Actual result | Status | Tester | Date |)") + m = re.search(r"^\|\s*Traces to\s*\|(.*)\|", body_text, re.MULTILINE) + if m: + traced_brs.update(BR_RX.findall(m.group(1))) + + # -- traceability matrix ------------------------------------------------------ + matrix_brs = {} + in_matrix = False + for line in lines: + if re.match(r"^##\s+.*Traceability Matrix", line): + in_matrix = True + continue + if in_matrix and re.match(r"^##\s+", line): + in_matrix = False + if in_matrix: + m = re.match(r"^\|\s*(BR-\d{3})\s*\|", line) + if m: + matrix_brs[m.group(1)] = set(TC_ID_RX.findall(line.split("|", 2)[-1])) + if not matrix_brs and (traced_brs or blocks): + findings.append("no traceability matrix rows found (section '## 5. Traceability Matrix')") + for br in sorted(traced_brs - set(matrix_brs)): + findings.append(f"traceability: {br} is traced by a TC but has no matrix row") + for br, tcs in sorted(matrix_brs.items()): + if not tcs: + findings.append(f"traceability: matrix row {br} lists no covering TC") + for tc in sorted("TC-%s-%s" % t for t in tcs): + if tc not in seen: + findings.append(f"traceability: matrix references '{tc}' which has no TC block") + + # -- leftover placeholders ------------------------------------------------------ + for n, line in enumerate(lines, 1): + m = PLACEHOLDER_RX.search(line) + if m: + findings.append(f"template placeholder '{m.group(0)}' left at line {n}") + for lit in ("TBD", "TODO"): + if re.search(r"\b" + lit + r"\b", line): + findings.append(f"'{lit}' left at line {n}") + + if findings: + for f_ in findings: + print(f"FAIL: {f_}") + print(f"\n{len(findings)} finding(s) in {path}") + return 1 + print(f"PASS: {path} — all checks clean") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(main(sys.argv[1]))