@tianhai/pi-workflow-kit 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """Build review evidence from explicit, immutable Git bounds. Python 3.9+, stdlib only."""
3
+
4
+ import argparse
5
+ import os
6
+ from pathlib import Path
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+
12
+
13
+ class PacketError(Exception):
14
+ pass
15
+
16
+
17
+ class Document:
18
+ """Keep original lines for evidence; inspect headings only outside fenced examples."""
19
+
20
+ def __init__(self, path):
21
+ with path.open(encoding='utf-8', newline='') as source:
22
+ self.lines = source.readlines()
23
+ self.headings = []
24
+ self.visible = set()
25
+ fence = None
26
+ for index, line in enumerate(self.lines):
27
+ marker = re.match(r'^ {0,3}(`{3,}|~{3,})(.*)$', line.rstrip('\r\n'))
28
+ if fence:
29
+ if marker and marker[1][0] == fence[0] and len(marker[1]) >= len(fence) and not marker[2].strip():
30
+ fence = None
31
+ continue
32
+ if marker:
33
+ fence = marker[1]
34
+ continue
35
+ self.visible.add(index)
36
+ heading = re.match(r'^ {0,3}(#{1,6})\s+(.+?)\s*#*\s*$', line.rstrip('\r\n'))
37
+ if heading:
38
+ self.headings.append((index, len(heading[1]), heading[2]))
39
+ if fence:
40
+ raise PacketError(f'{path.name}: unclosed code fence')
41
+
42
+ def section(self, title, level=2, required=False):
43
+ matches = [h for h in self.headings if h[1:] == (level, title)]
44
+ if len(matches) > 1 or (required and not matches):
45
+ raise PacketError(f'expected one {title} section; found {len(matches)}')
46
+ if not matches:
47
+ return None
48
+ start = matches[0][0]
49
+ end = next((i for i, depth, _ in self.headings if i > start and depth <= level), len(self.lines))
50
+ return start, end
51
+
52
+ def text(self, start, end):
53
+ return ''.join(self.lines[start:end])
54
+
55
+
56
+ def git(project, *args):
57
+ result = subprocess.run(
58
+ ['git', '-C', str(project), *args], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
59
+ env={**os.environ, 'GIT_OPTIONAL_LOCKS': '0', 'GIT_TERMINAL_PROMPT': '0'},
60
+ )
61
+ if result.returncode:
62
+ invocation = ' '.join(['git', *args])
63
+ raise PacketError(f'{invocation} failed: {result.stderr.decode("utf-8", errors="replace").strip()}')
64
+ return result.stdout.decode('utf-8', errors='replace')
65
+
66
+
67
+ def resolve_input(project, value):
68
+ path = Path(value)
69
+ return (path if path.is_absolute() else project / path).resolve(strict=True)
70
+
71
+
72
+ def paths(args):
73
+ project = Path(args.project).resolve(strict=True)
74
+ if Path(git(project, 'rev-parse', '--show-toplevel').strip()).resolve() != project:
75
+ raise PacketError('--project must be the Git worktree root')
76
+ for value in (args.design, args.progress):
77
+ supplied = Path(value)
78
+ supplied = Path(os.path.abspath(supplied if supplied.is_absolute() else project / supplied))
79
+ lexical_project = Path(os.path.abspath(args.project))
80
+ try:
81
+ relative_input = supplied.relative_to(lexical_project)
82
+ except ValueError:
83
+ relative_input = supplied.relative_to(project)
84
+ current = project
85
+ for part in relative_input.parts:
86
+ current = current / part
87
+ if current.is_symlink():
88
+ raise PacketError('design/progress paths must not traverse symlinks')
89
+ design = resolve_input(project, args.design)
90
+ progress = resolve_input(project, args.progress)
91
+ relative = design.relative_to(project / 'docs' / 'plans')
92
+ if len(relative.parts) != 2 or relative.parts[0] == 'completed' or not design.name.endswith('-design.md'):
93
+ raise PacketError('design must be docs/plans/<topic>/<leaf>-design.md in an active topic')
94
+ leaf = design.name[:-len('-design.md')]
95
+ if not leaf or progress != design.with_name(f'{leaf}-progress.md'):
96
+ raise PacketError('progress must be the matching <leaf>-progress.md beside the design')
97
+ # Resolving inputs must not route a packet write through symlinked topic directories.
98
+ lexical_topic = project / 'docs' / 'plans' / relative.parts[0]
99
+ for directory in (project / 'docs', project / 'docs' / 'plans', lexical_topic):
100
+ if directory.is_symlink():
101
+ raise PacketError('symlink topic directories are not writable packet destinations')
102
+ suffix = '' if args.scope == 'feature' else f'-r{args.requirement}'
103
+ destination = design.with_name(f'{leaf}-review-packet{suffix}.md')
104
+ validate_destination(destination)
105
+ return project, design, progress, destination
106
+
107
+
108
+ def validate_destination(path):
109
+ if path.is_symlink() or (path.exists() and not path.is_file()):
110
+ raise PacketError('packet destination must be a regular file, not a symlink or directory')
111
+
112
+
113
+ def requirements(document):
114
+ region = document.section('Requirements', required=True)
115
+ headings = []
116
+ for index, depth, title in document.headings:
117
+ match = re.match(r'^R([1-9][0-9]*):\s*\S', title)
118
+ if depth == 3 and re.match(r'^R[0-9]', title) and not match:
119
+ raise PacketError('malformed requirement heading')
120
+ if depth == 3 and match:
121
+ if not region[0] < index < region[1]:
122
+ raise PacketError('requirement heading outside Requirements section')
123
+ headings.append((int(match[1]), index))
124
+ numbers = [number for number, _ in headings]
125
+ if not numbers or len(set(numbers)) != len(numbers):
126
+ raise PacketError('missing or duplicate requirement headings')
127
+ return headings, region
128
+
129
+
130
+ def progress_rows(document, project, design):
131
+ first_heading = next((i for i, depth, _ in document.headings if depth == 2), len(document.lines))
132
+ pointers = [document.lines[i].strip()[8:] for i in document.visible
133
+ if i < first_heading and document.lines[i].startswith('Design: ')]
134
+ if len(pointers) != 1 or resolve_input(project, pointers[0]) != design:
135
+ raise PacketError('progress Design header must identify the selected design exactly once')
136
+ start, end = document.section('Requirements', required=True)
137
+ lines = [document.lines[i].strip() for i in range(start + 1, end)
138
+ if i in document.visible and document.lines[i].strip()]
139
+ if len(lines) < 3:
140
+ raise PacketError('missing Requirements table')
141
+
142
+ def cells(line):
143
+ if not line.startswith('|') or not line.endswith('|'):
144
+ raise PacketError('malformed Requirements table row')
145
+ return [cell.strip() for cell in re.split(r'(?<!\\)\|', line)[1:-1]]
146
+
147
+ header = cells(lines[0])
148
+ if header != ['#', 'Done', 'Requirement', 'Per-req ceremony', 'Commit']:
149
+ raise PacketError('Requirements table must have #, Done, Requirement, Per-req ceremony, Commit columns')
150
+ separator = cells(lines[1])
151
+ if len(separator) != 5 or not all(re.fullmatch(r':?-+:?', cell) for cell in separator):
152
+ raise PacketError('malformed Requirements table separator')
153
+ rows = {}
154
+ for line in lines[2:]:
155
+ row = cells(line)
156
+ if len(row) != 5 or not re.fullmatch(r'[1-9][0-9]*', row[0]):
157
+ raise PacketError('malformed Requirements table row')
158
+ number = int(row[0])
159
+ if number in rows:
160
+ raise PacketError(f'duplicate R{number} progress row')
161
+ if row[1] not in ('', '⬜', '🔄', '✅', '❌', '⏭'):
162
+ raise PacketError(f'R{number}: invalid Done cell')
163
+ rows[number] = row
164
+ return rows
165
+
166
+
167
+ def bounds(project, rows, headings, args):
168
+ numbers = [number for number, _ in headings]
169
+ position = numbers.index(args.requirement) if args.scope == 'requirement' else None
170
+ relevant = numbers if position is None else numbers[max(0, position - 1):position + 1]
171
+ if set(rows) - set(numbers):
172
+ raise PacketError('progress contains requirements absent from the design')
173
+ recorded = {}
174
+ previous = None
175
+ current_head = git(project, 'rev-parse', '--verify', 'HEAD^{commit}').strip()
176
+ for number in relevant:
177
+ if number not in rows:
178
+ raise PacketError(f'R{number}: missing progress row')
179
+ row = rows[number]
180
+ value = row[4]
181
+ if value in ('', '—', '-'):
182
+ if row[1] not in ('❌', '⏭'):
183
+ raise PacketError(f'R{number}: missing Commit entry')
184
+ recorded[number] = []
185
+ continue
186
+ # An ordered whitespace-separated list supports review-fix commits without losing
187
+ # the first feature commit. Other formats are ambiguous, never mined for hashes.
188
+ entries = value.split()
189
+ if not all(re.fullmatch(r'[0-9a-fA-F]{7,64}', entry) for entry in entries):
190
+ raise PacketError(f'R{number}: Commit must contain only ordered hexadecimal commit IDs')
191
+ commits = []
192
+ for entry in entries:
193
+ commit = git(project, 'rev-parse', '--verify', f'{entry}^{{commit}}').strip()
194
+ if previous:
195
+ if previous == commit:
196
+ raise PacketError('duplicate or overlapping recorded commits')
197
+ git(project, 'merge-base', '--is-ancestor', previous, commit)
198
+ git(project, 'merge-base', '--is-ancestor', commit, current_head)
199
+ commits.append(commit)
200
+ previous = commit
201
+ recorded[number] = commits
202
+ if args.scope == 'feature':
203
+ first = next((commits[0] for commits in recorded.values() if commits), None)
204
+ if not first:
205
+ raise PacketError('no recorded commits; cannot infer a feature base')
206
+ return git(project, 'rev-parse', '--verify', f'{first}^').strip(), current_head
207
+ selected = recorded[args.requirement]
208
+ if not selected:
209
+ raise PacketError('selected requirement has no recorded bound')
210
+ if position == 0:
211
+ base = git(project, 'rev-parse', '--verify', f'{selected[0]}^').strip()
212
+ else:
213
+ prior = recorded[numbers[position - 1]]
214
+ if not prior:
215
+ raise PacketError('previous requirement has no recorded bound; cannot guess a range')
216
+ base = prior[-1]
217
+ return base, selected[-1]
218
+
219
+
220
+ def evidence(document, headings, region, args):
221
+ feature = document.section('Feature acceptance')
222
+ if feature and feature[0] < region[1]:
223
+ raise PacketError('Feature acceptance must follow Requirements')
224
+ if args.scope == 'feature':
225
+ start = headings[0][1]
226
+ end = feature[0] if feature else len(document.lines)
227
+ else:
228
+ position = next(i for i, (number, _) in enumerate(headings) if number == args.requirement)
229
+ start = headings[position][1]
230
+ end = headings[position + 1][1] if position + 1 < len(headings) else region[1]
231
+ criteria = document.text(start, end)
232
+ acceptance = ''
233
+ if feature:
234
+ stop = next((i for i, depth, title in document.headings
235
+ if feature[0] < i < feature[1] and depth == 3 and title.startswith('Feature review:')), feature[1])
236
+ acceptance = document.text(feature[0], stop)
237
+ notes = []
238
+ for index, depth, title in document.headings:
239
+ if depth == 3 and title == 'Production-risk notes' and start <= index < end:
240
+ stop = next((i for i, d, t in document.headings if i > index and
241
+ (d <= 2 or (d == 3 and re.match(r'^R[0-9]+:', t)))), len(document.lines))
242
+ notes.append(document.text(index, min(stop, end)))
243
+ risk = document.section('Production-risk areas')
244
+ if risk:
245
+ notes.append(document.text(*risk))
246
+ return criteria, acceptance, ''.join(notes)
247
+
248
+
249
+ def build(args):
250
+ project, design_path, progress_path, destination = paths(args)
251
+ design = Document(design_path)
252
+ headings, region = requirements(design)
253
+ if args.scope == 'requirement' and args.requirement not in [n for n, _ in headings]:
254
+ raise PacketError('selected requirement is absent from the design')
255
+ rows = progress_rows(Document(progress_path), project, design_path)
256
+ base, head = bounds(project, rows, headings, args)
257
+ criteria, acceptance, notes = evidence(design, headings, region, args)
258
+ span = f'{base}..{head}'
259
+ pathspec = ['--', '.', ':(glob,exclude)**/*-review-packet*.md']
260
+ sections = [
261
+ ('Commits', git(project, 'log', '--oneline', '--no-decorate', span)),
262
+ ('Changed files', git(project, 'diff', '--no-ext-diff', '--no-textconv', '--stat', span, *pathspec)),
263
+ ('Acceptance criteria (verbatim from the design doc)', criteria),
264
+ ('Feature acceptance (verbatim)', acceptance),
265
+ ('Production-risk notes (verbatim, if any)', notes),
266
+ ('Diff', git(project, 'diff', '--no-ext-diff', '--no-textconv', '--no-color', span, *pathspec)),
267
+ ]
268
+ label = 'feature review' if args.scope == 'feature' else f'requirement R{args.requirement} review'
269
+ packet = f'# Review packet: {design_path.parent.name} — {label}\n\nBase: {base}\nHead: {head}\n\n'
270
+ for title, content in sections:
271
+ packet += f'## {title}\n\n{content}\n'
272
+ # All parsing and Git reads finish before a temporary file exists. A failure in
273
+ # assembly leaves an earlier complete packet intact. Replacement is same-directory.
274
+ temporary = None
275
+ try:
276
+ validate_destination(destination)
277
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', newline='', dir=destination.parent,
278
+ prefix=f'.{destination.name}.', delete=False) as output:
279
+ temporary = Path(output.name)
280
+ output.write(packet)
281
+ output.flush()
282
+ os.fsync(output.fileno())
283
+ validate_destination(destination)
284
+ os.replace(temporary, destination)
285
+ temporary = None
286
+ finally:
287
+ if temporary is not None:
288
+ temporary.unlink()
289
+ print(f'Wrote {destination}\nRange: {base}..{head}')
290
+
291
+
292
+ def main():
293
+ parser = argparse.ArgumentParser(description=__doc__, epilog=(
294
+ '--project resolves from the caller cwd and must be a Git worktree root. '
295
+ '--design and --progress are absolute or relative to --project, not the skill or cwd. '
296
+ 'Output is <leaf>-review-packet[-rN].md beside the matched docs in docs/plans/<topic>/. '
297
+ 'Commit cells contain ordered whitespace-separated hexadecimal commit IDs, not revision expressions.'
298
+ ))
299
+ parser.add_argument('--project', required=True)
300
+ parser.add_argument('--design', required=True)
301
+ parser.add_argument('--progress', required=True)
302
+ parser.add_argument('--scope', choices=['feature', 'requirement'], required=True)
303
+ parser.add_argument('--requirement', type=int)
304
+ args = parser.parse_args()
305
+ if (args.scope == 'requirement') != (args.requirement is not None):
306
+ parser.error('--requirement N is required only for --scope requirement')
307
+ try:
308
+ if sys.version_info < (3, 9):
309
+ raise PacketError('Python 3.9 or newer is required; install it explicitly before retrying')
310
+ build(args)
311
+ except (PacketError, OSError, ValueError) as error:
312
+ print(f'error: {error}', file=sys.stderr)
313
+ return 1
314
+ return 0
315
+
316
+
317
+ if __name__ == '__main__':
318
+ sys.exit(main())
@@ -37,7 +37,7 @@ Five sections, always: Summary / How it works / Key flows / Gotchas & invariants
37
37
  One subsection per component: responsibility + mechanism.
38
38
 
39
39
  ## Key flows
40
- Execution/data movement as arrow chains (A -> B -> C), anchored per step.
40
+ Execution/data movement as arrow chains (A -> B -> C), anchored per step, one flow per bullet line.
41
41
 
42
42
  ## Gotchas & invariants
43
43
  Edge cases, implicit assumptions, ordering constraints.