• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tired-labs / tiredize / 19547238585

20 Nov 2025 06:20PM UTC coverage: 95.332%. First build
19547238585

Pull #4

github

sludgework
Adding coveralls badge to main README.md
Pull Request #4: Code coverage now measured by coveralls

172 of 184 new or added lines in 3 files covered. (93.48%)

776 of 814 relevant lines covered (95.33%)

1.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.57
/tiredize/validators/markdown_structure.py
1
# tiredize/validators/markdown_structure.py
2

3
from __future__ import annotations
2✔
4

5
import re
2✔
6
from typing import Dict, List, Any, Tuple, Optional
2✔
7

8
from tiredize.document import Document, Heading, Table
2✔
9
from tiredize.types import RuleResult
2✔
10
from tiredize.markdown_schema import (
2✔
11
    load_markdown_schema,
12
    MarkdownSchema,
13
    SectionSpec,
14
    TableSpec,
15
    TableHeaderSpec,
16
    TableRowsSpec,
17
)
18

19

20
def validate_markdown_structure(
2✔
21
        doc: Document, schema_cfg: Dict[str, Any]
22
        ) -> List[RuleResult]:
23
    """
24
    Validate the markdown body against the configured structural schema.
25

26
    Enforces:
27
      - Required sections (by title or title_pattern) at specific heading
28
        levels
29
      - Required child sections under a parent section
30
      - Required tables under sections
31
      - Table header matching for tables attached to sections
32
    """
33
    results: List[RuleResult] = []
2✔
34

35
    # If no markdown schema is configured, this rule is a no-op.
36
    if "markdown" not in schema_cfg:
2✔
37
        return results
2✔
38

39
    md_schema: MarkdownSchema = load_markdown_schema(schema_cfg)
2✔
40

41
    # Treat the whole document as the parent span for top-level sections.
42
    parent_span = (0, _doc_end_line(doc))
2✔
43

44
    for sec_spec in md_schema.sections:
2✔
45
        sec_results = _validate_section_spec(
2✔
46
            doc=doc,
47
            spec=sec_spec,
48
            parent_span=parent_span,
49
        )
50
        results.extend(sec_results)
2✔
51

52
    return results
2✔
53

54

55
def _doc_end_line(doc: Document) -> int:
2✔
56
    """Return one-past-the-last line number of the document body."""
57
    return len(doc.body.splitlines()) + 1
2✔
58

59

60
def _match_heading_title(spec: SectionSpec, heading: Heading) -> bool:
2✔
61
    if spec.title is not None:
2✔
62
        return heading.title == spec.title
2✔
63
    if spec.title_pattern is not None:
2✔
64
        return re.match(spec.title_pattern, heading.title) is not None
2✔
NEW
65
    return False
×
66

67

68
def _find_matching_headings(
2✔
69
    doc: Document,
70
    spec: SectionSpec,
71
    parent_span: Tuple[int, int],
72
) -> List[Tuple[int, Heading]]:
73
    """
74
    Find headings that match this section spec within the given parent span.
75

76
    Returns list of (index_in_doc_headings, Heading).
77
    """
78
    start_line, end_line = parent_span
2✔
79
    matches: List[Tuple[int, Heading]] = []
2✔
80

81
    for idx, h in enumerate(doc.headings):
2✔
82
        if h.line <= start_line:
2✔
83
            continue
2✔
84
        if h.line >= end_line:
2✔
85
            break
2✔
86
        if h.level != spec.level:
2✔
87
            continue
2✔
88
        if not _match_heading_title(spec, h):
2✔
89
            continue
2✔
90
        matches.append((idx, h))
2✔
91

92
    return matches
2✔
93

94

95
def _find_section_span(
2✔
96
    doc: Document,
97
    heading_index: int,
98
    level: int,
99
    parent_span: Tuple[int, int],
100
) -> Tuple[int, int]:
101
    """
102
    Given the index of a heading in doc.headings and its level, compute the
103
    span of this section as (start_line_exclusive, end_line_exclusive).
104

105
    The span is bounded by:
106
      - the next heading at level <= this level inside the parent span, or
107
      - the parent span end if no such heading exists.
108
    """
109
    start_line = doc.headings[heading_index].line
2✔
110
    parent_start, parent_end = parent_span
2✔
111

112
    end_line = parent_end
2✔
113
    for j in range(heading_index + 1, len(doc.headings)):
2✔
114
        h = doc.headings[j]
2✔
115
        if h.line >= parent_end:
2✔
116
            break
2✔
117
        if h.level <= level:
2✔
118
            end_line = h.line
2✔
119
            break
2✔
120

121
    return (start_line, end_line)
2✔
122

123

124
def _validate_section_spec(
2✔
125
    doc: Document,
126
    spec: SectionSpec,
127
    parent_span: Tuple[int, int],
128
) -> List[RuleResult]:
129
    results: List[RuleResult] = []
2✔
130

131
    matches = _find_matching_headings(doc, spec, parent_span)
2✔
132

133
    # Required section missing entirely
134
    if not matches and spec.required:
2✔
135
        results.append(
2✔
136
            RuleResult(
137
                rule_id="md_section_missing",
138
                line=0,
139
                message=f"Required section '{spec.id}' (level {spec.level}) "
140
                "not found",
141
            )
142
        )
143
        # If the section is missing, we do not descend into children or tables.
144
        return results
2✔
145

146
    # Multiple occurrences where at most one is allowed
147
    if not spec.repeatable and len(matches) > 1:
2✔
NEW
148
        results.append(
×
149
            RuleResult(
150
                rule_id="md_section_multiple",
151
                line=matches[1][1].line,
152
                message=f"Section '{spec.id}' appears more than once",
153
            )
154
        )
155

156
    # For each matched section heading, validate its tables and children
157
    for idx, heading in matches:
2✔
158
        section_span = _find_section_span(
2✔
159
            doc=doc,
160
            heading_index=idx,
161
            level=spec.level,
162
            parent_span=parent_span,
163
        )
164

165
        # Validate tables anchored to this section
166
        results.extend(
2✔
167
            _validate_tables_in_section(
168
                doc=doc,
169
                spec=spec,
170
                section_span=section_span,
171
            )
172
        )
173

174
        # Validate child sections inside this section span
175
        for child_spec in spec.children:
2✔
176
            results.extend(
2✔
177
                _validate_section_spec(
178
                    doc=doc,
179
                    spec=child_spec,
180
                    parent_span=section_span,
181
                )
182
            )
183

184
    return results
2✔
185

186

187
def _tables_in_span(doc: Document, span: Tuple[int, int]) -> List[Table]:
2✔
188
    start_line, end_line = span
2✔
189
    return [
2✔
190
        t
191
        for t in doc.tables
192
        if t.start_line > start_line and t.start_line < end_line
193
    ]
194

195

196
def _validate_table_header(
2✔
197
    table: Table,
198
    t_spec: TableSpec,
199
    parent_section_id: str,
200
) -> Optional[RuleResult]:
201
    header_spec: Optional[TableHeaderSpec] = t_spec.header
2✔
202
    if header_spec is None:
2✔
NEW
203
        return None
×
204

205
    expected = header_spec.columns
2✔
206
    actual = table.header
2✔
207

208
    if header_spec.match == "exact":
2✔
209
        if actual != expected:
2✔
210
            return RuleResult(
2✔
211
                rule_id="md_table_header",
212
                line=table.start_line,
213
                message=(
214
                    f"Table '{t_spec.id}' in section '{parent_section_id}' "
215
                    f"has header {actual}, expected {expected}"
216
                ),
217
            )
218
        return None
2✔
219

NEW
220
    if header_spec.match == "superset":
×
221
        # actual must contain expected columns in order
NEW
222
        try:
×
NEW
223
            idx = 0
×
NEW
224
            for col in expected:
×
NEW
225
                idx = actual.index(col, idx) + 1
×
NEW
226
        except ValueError:
×
NEW
227
            return RuleResult(
×
228
                rule_id="md_table_header",
229
                line=table.start_line,
230
                message=(
231
                    f"Table '{t_spec.id}' in section '{parent_section_id}' "
232
                    f"does not contain expected header columns {expected}"
233
                ),
234
            )
NEW
235
        return None
×
236

237
    # Unknown match mode; treat as configuration error
NEW
238
    return RuleResult(
×
239
        rule_id="md_table_header",
240
        line=table.start_line,
241
        message=(
242
            f"Unsupported header.match mode '{header_spec.match}' "
243
            f"for table '{t_spec.id}'"
244
        ),
245
    )
246

247

248
def _validate_tables_in_section(
2✔
249
    doc: Document,
250
    spec: SectionSpec,
251
    section_span: Tuple[int, int],
252
) -> List[RuleResult]:
253
    results: List[RuleResult] = []
2✔
254

255
    if not spec.tables:
2✔
256
        return results
2✔
257

258
    tables_in_section = _tables_in_span(doc, section_span)
2✔
259

260
    for t_spec in spec.tables:
2✔
261
        if tables_in_section:
2✔
262
            table: Optional[Table] = tables_in_section[0]
2✔
263
        else:
264
            table: Optional[Table] = None
2✔
265

266
        if table is None:
2✔
267
            if t_spec.required:
2✔
268
                results.append(
2✔
269
                    RuleResult(
270
                        rule_id="md_table_missing",
271
                        line=section_span[0],
272
                        message=(
273
                            f"Required table '{t_spec.id}' "
274
                            f"not found in section '{spec.id}'"
275
                        ),
276
                    )
277
                )
278
            continue
2✔
279

280
        # Header validation
281
        if t_spec.header is not None:
2✔
282
            header_result = _validate_table_header(
2✔
283
                table=table,
284
                t_spec=t_spec,
285
                parent_section_id=spec.id,
286
            )
287
            if header_result is not None:
2✔
288
                results.append(header_result)
2✔
289

290
        # Row count validation
291
        if t_spec.rows is not None:
2✔
292
            rows_result = _validate_table_rows(
2✔
293
                table=table,
294
                rows_spec=t_spec.rows,
295
                table_id=t_spec.id,
296
                parent_section_id=spec.id,
297
            )
298
            if rows_result is not None:
2✔
299
                results.append(rows_result)
2✔
300

301
    return results
2✔
302

303

304
def _validate_table_rows(
2✔
305
    table: Table,
306
    rows_spec: TableRowsSpec,
307
    table_id: str,
308
    parent_section_id: str,
309
) -> Optional[RuleResult]:
310
    row_count = len(table.rows)
2✔
311

312
    if rows_spec.min is not None and row_count < rows_spec.min:
2✔
313
        return RuleResult(
2✔
314
            rule_id="md_table_rows",
315
            line=table.start_line,
316
            message=(
317
                f"Table '{table_id}' in section '{parent_section_id}' "
318
                f"has {row_count} rows, expected at least {rows_spec.min} rows"
319
            ),
320
        )
321

322
    if rows_spec.max is not None and row_count > rows_spec.max:
2✔
323
        return RuleResult(
2✔
324
            rule_id="md_table_rows",
325
            line=table.start_line,
326
            message=(
327
                f"Table '{table_id}' in section '{parent_section_id}' "
328
                f"has {row_count} rows, expected at most {rows_spec.max} rows"
329
            ),
330
        )
331

332
    return None
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc