space-architect 5.5.0 → 6.0.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -0
- data/lib/space_architect/architect_project.rb +542 -56
- data/lib/space_architect/bug_report.rb +18 -7
- data/lib/space_architect/cli/architect.rb +123 -7
- data/lib/space_architect/cli/research.rb +3 -4
- data/lib/space_architect/harness.rb +73 -28
- data/lib/space_architect/session_sync/runner.rb +4 -2
- data/lib/space_architect/skill_installer.rb +3 -3
- data/lib/space_architect/templates/iteration.md.erb +35 -7
- data/lib/space_core/cli/help.rb +2 -0
- data/lib/space_core/commands.rb +32 -1
- data/lib/space_core/paths.rb +35 -0
- data/lib/space_core/space_store.rb +1 -1
- data/lib/space_core/version.rb +1 -1
- data/lib/space_src/cli/sync.rb +23 -10
- data/lib/space_src/cli.rb +1 -0
- data/lib/space_src/cloner.rb +2 -0
- data/lib/space_src/nav.rb +1 -0
- data/lib/space_src/sync/engine.rb +46 -7
- data/lib/space_src/sync/report.rb +18 -0
- data/lib/space_src.rb +1 -0
- data/skill/architect/SKILL.md +96 -14
- data/skill/architect/dispatch.md +27 -12
- metadata +2 -1
|
@@ -7,6 +7,8 @@ require "fileutils"
|
|
|
7
7
|
require "pathname"
|
|
8
8
|
require "tempfile"
|
|
9
9
|
require "time"
|
|
10
|
+
require "digest"
|
|
11
|
+
require "shellwords"
|
|
10
12
|
|
|
11
13
|
module Space::Architect
|
|
12
14
|
# Manages an architect-loop project inside a space: one self-contained file per
|
|
@@ -42,6 +44,23 @@ module Space::Architect
|
|
|
42
44
|
# Hard per-gate timeout. Generous relative to the full suite (~55s).
|
|
43
45
|
DEFAULT_GATE_TIMEOUT = 900
|
|
44
46
|
|
|
47
|
+
# The scaffold's untouched placeholder AC1 line — templates/iteration.md.erb's
|
|
48
|
+
# Acceptance Criteria section (named, not line-numbered: a pinned line number
|
|
49
|
+
# here has already drifted twice). Pinned by contract with the authoring
|
|
50
|
+
# lane, which is forbidden from changing it, precisely so freeze!'s #73
|
|
51
|
+
# hard-refuse (below) can key on it.
|
|
52
|
+
AC1_PLACEHOLDER = "**AC1.** ..."
|
|
53
|
+
|
|
54
|
+
# Rehearsal's BROKEN heuristic (I09/AC5): a command-not-found exit code or a
|
|
55
|
+
# shell parse failure, distinct from a clean non-zero (RED — the gate
|
|
56
|
+
# discriminates). Advisory, not authoritative — see #classify_rehearsal.
|
|
57
|
+
BROKEN_STDERR_PATTERN = /\bsyntax error\b|unexpected end of file|unexpected eof/i
|
|
58
|
+
|
|
59
|
+
# I09/AC9(b): a gate `cmd` that carries a literal 'repos/<name>/' prefix
|
|
60
|
+
# with no `cwd` — legal, occasionally correct, but usually a leftover
|
|
61
|
+
# space-root-relative path since `cmd` already resolves against the repo tree.
|
|
62
|
+
BARE_REPO_PREFIX = %r{(?<![\w./-])repos/[^/\s'"]+/}
|
|
63
|
+
|
|
45
64
|
# Legacy sentinel: worktree_add used to seed prompt.md with this placeholder
|
|
46
65
|
# (dropped — the blind-overwrite tripped harness read-before-write guards, #48).
|
|
47
66
|
# dispatch still refuses to launch on this content, so stubs in old spaces
|
|
@@ -146,6 +165,7 @@ module Space::Architect
|
|
|
146
165
|
block = space.data["project"] || {}
|
|
147
166
|
architecture_dir = space.path.join("architecture")
|
|
148
167
|
iteration_files = if architecture_dir.exist?
|
|
168
|
+
# paths:exempt - the /\AI\d+-.+\.md\z/ filter structurally cannot match a dotfile-prefixed name, so raw enumeration is already dotfile-safe here
|
|
149
169
|
architecture_dir.children
|
|
150
170
|
.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
|
|
151
171
|
.map { |f| f.basename.to_s }.sort
|
|
@@ -159,7 +179,7 @@ module Space::Architect
|
|
|
159
179
|
# any pending changes to the iteration file and records HEAD as freeze_sha. If
|
|
160
180
|
# already frozen, refuses when the frozen region has changed since.
|
|
161
181
|
# With force: true, re-freezes a changed frozen region if no lane is dispatched yet.
|
|
162
|
-
def freeze!(iteration, warnings: nil, message: nil, force: false)
|
|
182
|
+
def freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil)
|
|
163
183
|
entry = slice_entry(iteration)
|
|
164
184
|
rel = entry["file"]
|
|
165
185
|
path = space.path.join(rel)
|
|
@@ -172,6 +192,19 @@ module Space::Architect
|
|
|
172
192
|
lint_gates!(text, warnings: warnings)
|
|
173
193
|
lint_lanes!(text)
|
|
174
194
|
|
|
195
|
+
if untouched_ac_placeholder?(text)
|
|
196
|
+
raise Space::Core::Error,
|
|
197
|
+
"#{rel}'s Acceptance Criteria still carries the scaffold placeholder '#{AC1_PLACEHOLDER}' with no " \
|
|
198
|
+
"active gate — write the real Acceptance Criteria (a hand-authored prose-only AC without the " \
|
|
199
|
+
"placeholder still freezes) before freezing."
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
if skip_rehearse_reason
|
|
203
|
+
raise Space::Core::Error, "--skip-rehearse requires a non-empty REASON" if skip_rehearse_reason.to_s.strip.empty?
|
|
204
|
+
else
|
|
205
|
+
ensure_rehearsed!(iteration, entry, text)
|
|
206
|
+
end
|
|
207
|
+
|
|
175
208
|
if entry["freeze_sha"]
|
|
176
209
|
sha = entry["freeze_sha"]
|
|
177
210
|
if frozen_region_changed?(sha, rel)
|
|
@@ -204,6 +237,7 @@ module Space::Architect
|
|
|
204
237
|
next unless s["name"] == iteration
|
|
205
238
|
s["freeze_sha"] = sha
|
|
206
239
|
s["verdict"] ||= "pending"
|
|
240
|
+
s["rehearsal_skip_reason"] = skip_rehearse_reason.strip if skip_rehearse_reason
|
|
207
241
|
lanes = s["lanes"] || []
|
|
208
242
|
declared.each do |d|
|
|
209
243
|
fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
|
|
@@ -314,6 +348,9 @@ module Space::Architect
|
|
|
314
348
|
|
|
315
349
|
# Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
|
|
316
350
|
# the Builder Report section and commit. Byte-for-byte: no summarization, no judgment.
|
|
351
|
+
# Re-transcribing a lane replaces its existing "### <lane>" subsection in place
|
|
352
|
+
# (preserving the order lanes were already transcribed in) instead of appending a
|
|
353
|
+
# duplicate; a lane not yet present still appends.
|
|
317
354
|
def transcribe_evidence!(iteration, lane: nil, message: nil)
|
|
318
355
|
entry = slice_entry(iteration)
|
|
319
356
|
rel = entry["file"]
|
|
@@ -326,8 +363,15 @@ module Space::Architect
|
|
|
326
363
|
raw = report.read
|
|
327
364
|
raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty?
|
|
328
365
|
|
|
329
|
-
|
|
330
|
-
|
|
366
|
+
text = path.read
|
|
367
|
+
new_body =
|
|
368
|
+
if lane
|
|
369
|
+
lane_names = (entry["lanes"] || []).map { |l| l["name"] }
|
|
370
|
+
replace_lane_report(section_body(text, "## Builder Report").to_s, lane, raw.rstrip, lane_names)
|
|
371
|
+
else
|
|
372
|
+
raw.rstrip
|
|
373
|
+
end
|
|
374
|
+
path.write(replace_section_body(text, "## Builder Report", new_body, append: false))
|
|
331
375
|
|
|
332
376
|
nn = format("%02d", entry["ordinal"] || 0)
|
|
333
377
|
git_capture("-C", space.path.to_s, "commit", "-m",
|
|
@@ -359,7 +403,20 @@ module Space::Architect
|
|
|
359
403
|
# the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
|
|
360
404
|
# Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
|
|
361
405
|
# (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
|
|
362
|
-
|
|
406
|
+
#
|
|
407
|
+
# accept_bounds_reason overrides ONLY the in-bounds check — never no_builder_commits,
|
|
408
|
+
# which stays an unconditional refusal (a builder commit is tampering, not an authoring
|
|
409
|
+
# defect the architect can rule on). Modeled on freeze!'s --skip-rehearse: a non-empty
|
|
410
|
+
# REASON is required whenever passed, recorded in space.yaml beside the lane, and
|
|
411
|
+
# returned for the caller to echo — the override can never be silent. Recorded only
|
|
412
|
+
# for THIS lane, and only when its in-bounds check actually failed — integrate! passes
|
|
413
|
+
# the same reason to every lane in the set, but a lane that was already in bounds gets
|
|
414
|
+
# neither the record nor the echo.
|
|
415
|
+
def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
|
|
416
|
+
if accept_bounds_reason
|
|
417
|
+
raise Space::Core::Error, "--accept-bounds requires a non-empty REASON" if accept_bounds_reason.to_s.strip.empty?
|
|
418
|
+
end
|
|
419
|
+
|
|
363
420
|
entry = slice_entry(iteration)
|
|
364
421
|
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
365
422
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
@@ -369,8 +426,10 @@ module Space::Architect
|
|
|
369
426
|
if checks[:no_builder_commits] == false
|
|
370
427
|
raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
|
|
371
428
|
end
|
|
372
|
-
if checks[:in_bounds] == false
|
|
373
|
-
raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset
|
|
429
|
+
if checks[:in_bounds] == false && !accept_bounds_reason
|
|
430
|
+
raise Space::Core::Error, "Lane '#{lane}' wrote outside its declared touch set — out-of-bounds fails the lane. Reset " \
|
|
431
|
+
"and re-dispatch, or `architect integrate #{iteration} --lanes #{lane} --accept-bounds REASON` to override when " \
|
|
432
|
+
"the touch-set declaration itself is the defect."
|
|
374
433
|
end
|
|
375
434
|
|
|
376
435
|
repo = lane_entry["repo"]
|
|
@@ -404,10 +463,7 @@ module Space::Architect
|
|
|
404
463
|
git_capture("-C", repo_path.to_s, "merge", "--abort")
|
|
405
464
|
conflict_files = conflicts.split
|
|
406
465
|
lane_touch_set = lane_entry["touch_set"] || []
|
|
407
|
-
|
|
408
|
-
outside = conflict_files.reject do |f|
|
|
409
|
-
lane_touch_set.any? { |g| File.fnmatch(g, f, fnm) || (g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm)) }
|
|
410
|
-
end
|
|
466
|
+
outside = conflict_files.reject { |f| in_touch_set?(f, lane_touch_set) }
|
|
411
467
|
if !lane_touch_set.empty? && outside.empty?
|
|
412
468
|
raise Space::Core::Error,
|
|
413
469
|
"Merge conflict integrating lane '#{lane}' (#{conflict_files.join(", ")}) — the lane plan was " \
|
|
@@ -423,6 +479,8 @@ module Space::Architect
|
|
|
423
479
|
merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
|
|
424
480
|
diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")
|
|
425
481
|
|
|
482
|
+
bounds_override_reason = accept_bounds_reason.strip if accept_bounds_reason && checks[:in_bounds] == false
|
|
483
|
+
|
|
426
484
|
update_architect_block do |b|
|
|
427
485
|
b["integration_branch"] = integration_branch
|
|
428
486
|
(b["iterations"] || []).each do |s|
|
|
@@ -431,27 +489,36 @@ module Space::Architect
|
|
|
431
489
|
next unless l["name"] == lane
|
|
432
490
|
l["integration_branch"] = integration_branch
|
|
433
491
|
l["integrate_sha"] = integrate_sha
|
|
492
|
+
l["bounds_override_reason"] = bounds_override_reason if bounds_override_reason
|
|
434
493
|
end
|
|
435
494
|
end
|
|
436
495
|
b
|
|
437
496
|
end
|
|
438
497
|
|
|
439
|
-
{ lane: lane, repo: repo, integration_branch: integration_branch,
|
|
440
|
-
|
|
498
|
+
{ lane: lane, repo: repo, integration_branch: integration_branch, merge_sha: merge_sha.strip,
|
|
499
|
+
base_sha: base_sha, diffstat: diffstat.strip, gates_run: false,
|
|
500
|
+
bounds_override_reason: bounds_override_reason }
|
|
441
501
|
end
|
|
442
502
|
|
|
443
503
|
# Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
|
|
444
504
|
# first conflict (a disjointness defect). Never decides which lanes pass. With no
|
|
445
505
|
# lanes and teardown: true, tears down every lane recorded for the iteration instead
|
|
446
506
|
# (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
|
|
447
|
-
|
|
507
|
+
#
|
|
508
|
+
# merge_lane!/teardown_lanes! only save integration_branch/integrate_sha/worktree to
|
|
509
|
+
# space.yaml on disk (update_architect_block never commits) — one commit per call,
|
|
510
|
+
# here, by pathspec, so which lanes merged survives independently of whether a
|
|
511
|
+
# Verdict follows (I13/A3). Always attempted, success or raise, so a conflict that
|
|
512
|
+
# stops the loop midway doesn't lose the lanes already merged.
|
|
513
|
+
def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil, accept_bounds_reason: nil)
|
|
448
514
|
lanes = Array(lanes)
|
|
449
515
|
return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
|
|
450
516
|
raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
|
|
451
517
|
|
|
452
518
|
merged = []
|
|
453
519
|
lanes.each do |lane|
|
|
454
|
-
merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into
|
|
520
|
+
merged << merge_lane!(iteration, lane, message: message, commit_mode: commit_mode, into: into,
|
|
521
|
+
accept_bounds_reason: accept_bounds_reason)
|
|
455
522
|
rescue Space::Core::Error => e
|
|
456
523
|
done = merged.map { |m| m[:lane] }.join(", ")
|
|
457
524
|
raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
|
|
@@ -459,6 +526,8 @@ module Space::Architect
|
|
|
459
526
|
|
|
460
527
|
teardown_lanes!(iteration, merged) if teardown
|
|
461
528
|
merged
|
|
529
|
+
ensure
|
|
530
|
+
commit_metadata_mutation!(iteration, message: message)
|
|
462
531
|
end
|
|
463
532
|
|
|
464
533
|
# Run the iteration's frozen Acceptance Criteria gate commands. Each gate is
|
|
@@ -466,7 +535,10 @@ module Space::Architect
|
|
|
466
535
|
# a hard timeout, and evaluated against its `expect` block. Returns an array
|
|
467
536
|
# of result hashes with :status (:pass/:fail) and :reason in addition to the
|
|
468
537
|
# raw :stdout/:stderr/:exit_code. The mechanical verdict belongs here; the AC
|
|
469
|
-
# verdict remains the architect's.
|
|
538
|
+
# verdict remains the architect's. WHERE the gate text comes from (the frozen
|
|
539
|
+
# commit) is resolved here; HOW gates execute is #execute_gates, shared
|
|
540
|
+
# byte-for-byte with #rehearse so the two can never run gates through
|
|
541
|
+
# different instruments (I09/AC3).
|
|
470
542
|
def run_gates(iteration, lane: nil)
|
|
471
543
|
entry = slice_entry(iteration)
|
|
472
544
|
freeze_sha = entry["freeze_sha"]
|
|
@@ -494,37 +566,50 @@ module Space::Architect
|
|
|
494
566
|
end
|
|
495
567
|
raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
|
|
496
568
|
|
|
497
|
-
gates
|
|
498
|
-
|
|
499
|
-
dir =
|
|
500
|
-
if (cwd = g["cwd"])
|
|
501
|
-
gate_cwd = space.path.join(cwd)
|
|
502
|
-
if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
|
|
503
|
-
base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
|
|
504
|
-
else
|
|
505
|
-
gate_cwd
|
|
506
|
-
end
|
|
507
|
-
else
|
|
508
|
-
base_dir
|
|
509
|
-
end
|
|
510
|
-
raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
|
|
569
|
+
execute_gates(gates, base_dir: base_dir, lane: lane, repo_root: repo_root)
|
|
570
|
+
end
|
|
511
571
|
|
|
512
|
-
|
|
513
|
-
|
|
572
|
+
# Rehearse the DRAFTED gates in the WORKING-TREE iteration file — before the
|
|
573
|
+
# freeze, while they can still be fixed — through the identical execution
|
|
574
|
+
# path #run_gates uses at judge time (#execute_gates). space.yaml records no
|
|
575
|
+
# lanes until freeze! writes them (#freeze!, :~209), so the run directory is
|
|
576
|
+
# resolved from the DRAFTED ```lanes``` block instead of the recorded one;
|
|
577
|
+
# rehearsal always runs in the repo checkout (repos/<repo>), never a lane
|
|
578
|
+
# worktree, because lane worktrees are not provisioned until after the
|
|
579
|
+
# freeze. Classifies each result RED/GREEN/BROKEN (I09/AC5) and stamps the
|
|
580
|
+
# iteration as rehearsed, keyed to the gates block's content (I09/AC7) — the
|
|
581
|
+
# stamp records that the architect looked, never that gates passed.
|
|
582
|
+
def rehearse(iteration, now: Time.now)
|
|
583
|
+
entry = slice_entry(iteration)
|
|
584
|
+
rel = entry["file"]
|
|
585
|
+
path = space.path.join(rel)
|
|
586
|
+
raise Space::Core::Error, "#{rel} does not exist — run `architect new #{iteration}` first" unless path.exist?
|
|
587
|
+
text = path.read
|
|
514
588
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
589
|
+
gates = parse_gates(text)
|
|
590
|
+
repo, base_dir, gate_results, scope_report =
|
|
591
|
+
if gates.empty?
|
|
592
|
+
[nil, nil, [], nil]
|
|
518
593
|
else
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
594
|
+
r = resolve_rehearsal_repo(iteration, text)
|
|
595
|
+
dir = space.path.join("repos", r)
|
|
596
|
+
raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
|
|
597
|
+
results = execute_gates(gates, base_dir: dir, lane: nil, repo_root: nil)
|
|
598
|
+
.map { |g| g.merge(rehearsal: classify_rehearsal(g)) }
|
|
599
|
+
[r, dir, results, scope_asymmetry_report(gates, text, dir)]
|
|
522
600
|
end
|
|
523
601
|
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
602
|
+
digest = gates_digest(text)
|
|
603
|
+
update_architect_block do |b|
|
|
604
|
+
(b["iterations"] || []).each do |s|
|
|
605
|
+
next unless s["name"] == iteration
|
|
606
|
+
s["rehearsal"] = { "gates_digest" => digest, "at" => now.iso8601 }
|
|
607
|
+
end
|
|
608
|
+
b
|
|
527
609
|
end
|
|
610
|
+
|
|
611
|
+
{ iteration: iteration, repo: repo, base_dir: base_dir, gates: gate_results,
|
|
612
|
+
empty: gates.empty?, placeholder: untouched_ac_placeholder?(text), scope_asymmetry: scope_report }
|
|
528
613
|
end
|
|
529
614
|
|
|
530
615
|
# Emit grounding reads for the architect's SessionStart hook.
|
|
@@ -785,7 +870,7 @@ module Space::Architect
|
|
|
785
870
|
def worktree_list
|
|
786
871
|
wt_base = space.path.join("build")
|
|
787
872
|
return [] unless wt_base.exist?
|
|
788
|
-
wt_base.
|
|
873
|
+
Space::Core::Paths.layout_children(wt_base).select(&:directory?).map { |p| p.basename.to_s }.sort
|
|
789
874
|
end
|
|
790
875
|
|
|
791
876
|
# Materialize the iteration's declared lanes: for each lane (or the one named via
|
|
@@ -998,6 +1083,21 @@ module Space::Architect
|
|
|
998
1083
|
body.empty? ? composed : "#{composed}\n\n#{body}"
|
|
999
1084
|
end
|
|
1000
1085
|
|
|
1086
|
+
# integrate!'s own space.yaml commit, by pathspec — like record_verdict!'s sweep, so
|
|
1087
|
+
# other uncommitted work in the space isn't pulled in. A call that mutated nothing
|
|
1088
|
+
# (teardown-only over lanes with no worktree, or a call that raised before touching
|
|
1089
|
+
# anything) leaves git commit with nothing staged for that path; tolerate that exit
|
|
1090
|
+
# via git_capture the way write_section! tolerates a no-op frozen-section commit,
|
|
1091
|
+
# never git_run it.
|
|
1092
|
+
def commit_metadata_mutation!(iteration, message:)
|
|
1093
|
+
entries = (space.data["project"] || {})["iterations"] || []
|
|
1094
|
+
ordinal = entries.find { |s| s["name"] == iteration }&.dig("ordinal") || 0
|
|
1095
|
+
nn = format("%02d", ordinal)
|
|
1096
|
+
git_capture("-C", space.path.to_s, "commit", "-m",
|
|
1097
|
+
compose_message("I#{nn} integrate:", "I#{nn}: record integration", message),
|
|
1098
|
+
"--", Space::Core::Space::METADATA_FILE)
|
|
1099
|
+
end
|
|
1100
|
+
|
|
1001
1101
|
# Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
|
|
1002
1102
|
# either merge_lane! results (symbol keys) or recorded lane entries (string
|
|
1003
1103
|
# keys) — both carry a lane name and a repo.
|
|
@@ -1031,6 +1131,7 @@ module Space::Architect
|
|
|
1031
1131
|
end
|
|
1032
1132
|
end
|
|
1033
1133
|
|
|
1134
|
+
# paths:exempt - the /\AI\d+-.+\.md\z/ filter structurally cannot match a dotfile-prefixed name, so raw enumeration is already dotfile-safe here
|
|
1034
1135
|
candidates = arch_dir.children.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
|
|
1035
1136
|
return nil if candidates.empty?
|
|
1036
1137
|
candidates.max_by { |f| f.basename.to_s[/\AI(\d+)/, 1].to_i }
|
|
@@ -1042,7 +1143,7 @@ module Space::Architect
|
|
|
1042
1143
|
def capture_with_timeout(cmd, dir:, timeout:)
|
|
1043
1144
|
out_f = Tempfile.new(["gate-stdout", ".log"])
|
|
1044
1145
|
err_f = Tempfile.new(["gate-stderr", ".log"])
|
|
1045
|
-
pid = Process.spawn(cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
|
|
1146
|
+
pid = Process.spawn("/bin/sh", "-c", cmd, pgroup: true, chdir: dir.to_s, out: out_f.path, err: err_f.path)
|
|
1046
1147
|
|
|
1047
1148
|
deadline = Time.now + timeout
|
|
1048
1149
|
status = nil
|
|
@@ -1072,6 +1173,311 @@ module Space::Architect
|
|
|
1072
1173
|
err_f&.close!
|
|
1073
1174
|
end
|
|
1074
1175
|
|
|
1176
|
+
# HOW gates execute — the one instrument #run_gates (judge time) and
|
|
1177
|
+
# #rehearse (pre-freeze) both call, byte-for-byte: same cwd-remap semantics,
|
|
1178
|
+
# same shell (capture_with_timeout's Process.spawn), same GateEvaluator,
|
|
1179
|
+
# same timeout handling (I09/AC3). repo_root/lane are nil outside a lane
|
|
1180
|
+
# context (rehearsal never has one — it always runs in the repo checkout).
|
|
1181
|
+
def execute_gates(gates, base_dir:, lane:, repo_root:)
|
|
1182
|
+
gates.map do |gate|
|
|
1183
|
+
g = gate.transform_keys(&:to_s)
|
|
1184
|
+
dir =
|
|
1185
|
+
if (cwd = g["cwd"])
|
|
1186
|
+
gate_cwd = space.path.join(cwd)
|
|
1187
|
+
if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
|
|
1188
|
+
base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
|
|
1189
|
+
else
|
|
1190
|
+
gate_cwd
|
|
1191
|
+
end
|
|
1192
|
+
else
|
|
1193
|
+
base_dir
|
|
1194
|
+
end
|
|
1195
|
+
raise Space::Core::Error, "directory does not exist: #{dir}" unless dir.exist?
|
|
1196
|
+
|
|
1197
|
+
effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
|
|
1198
|
+
captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
|
|
1199
|
+
|
|
1200
|
+
if captured[:timed_out]
|
|
1201
|
+
status = :fail
|
|
1202
|
+
reason = "timed out after #{effective}s"
|
|
1203
|
+
else
|
|
1204
|
+
ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
|
|
1205
|
+
status = ev.pass? ? :pass : :fail
|
|
1206
|
+
reason = ev.reason
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
{ id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
|
|
1210
|
+
stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
|
|
1211
|
+
dir: dir, status: status, reason: reason, timed_out: captured[:timed_out] }
|
|
1212
|
+
end
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
# I09/AC4: resolve rehearsal's run repo from the DRAFTED ```lanes``` block —
|
|
1216
|
+
# one repo, unambiguously declared or inferable, else a message naming what
|
|
1217
|
+
# to do. Never from space.yaml (no lanes are recorded there pre-freeze).
|
|
1218
|
+
def resolve_rehearsal_repo(iteration, text)
|
|
1219
|
+
declared = parse_lanes(text).filter_map { |l| l["repo"] }.uniq
|
|
1220
|
+
return declared.first if declared.size == 1
|
|
1221
|
+
|
|
1222
|
+
if declared.empty?
|
|
1223
|
+
tracked = space.repos.map { |r| r["name"] }
|
|
1224
|
+
return tracked.first if tracked.size == 1
|
|
1225
|
+
raise Space::Core::Error,
|
|
1226
|
+
"Cannot resolve a repo to rehearse '#{iteration}' against — no lane declares a repo and the space " \
|
|
1227
|
+
"tracks #{tracked.size} repos (#{tracked.join(', ')}). Declare a ```lanes``` block in the " \
|
|
1228
|
+
"Specification, naming the repo to rehearse against."
|
|
1229
|
+
end
|
|
1230
|
+
|
|
1231
|
+
raise Space::Core::Error,
|
|
1232
|
+
"Cannot resolve a single repo to rehearse '#{iteration}' against — the drafted lanes block names " \
|
|
1233
|
+
"multiple repos (#{declared.join(', ')}). Rehearsal runs once, against one repo checkout; narrow the " \
|
|
1234
|
+
"```lanes``` block before rehearsing."
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
# I09/AC5: RED (clean non-zero — discriminates) vs GREEN (passes on base) vs
|
|
1238
|
+
# BROKEN (127, timeout, or a shell parse failure). BROKEN is advisory, not
|
|
1239
|
+
# authoritative: a correct RED can look broken (e.g. `grep -q x new_file`
|
|
1240
|
+
# exits 2 — "No such file" — when the file is one the lane will write); the
|
|
1241
|
+
# CLI names this suspicion, the architect confirms it.
|
|
1242
|
+
def classify_rehearsal(result)
|
|
1243
|
+
return :broken if result[:timed_out] || result[:exit_code] == 127
|
|
1244
|
+
return :broken if BROKEN_STDERR_PATTERN.match?(result[:stderr].to_s)
|
|
1245
|
+
result[:status] == :pass ? :green : :red
|
|
1246
|
+
end
|
|
1247
|
+
|
|
1248
|
+
# grep-family binaries this check recognizes in a gate's shell command.
|
|
1249
|
+
SCOPE_GREP_BINARIES = %w[grep egrep fgrep].freeze
|
|
1250
|
+
|
|
1251
|
+
# Shellwords treats shell syntax — pipes, `&&`/`||`/`;`, `$(...)`, control
|
|
1252
|
+
# keywords — as ordinary word characters, not structure (its own docs say
|
|
1253
|
+
# plainly this isn't a command-line parser). These are the markers
|
|
1254
|
+
# #shell_segments splits a token stream on, once #pad_shell_operators has
|
|
1255
|
+
# made sure none of them can arrive glued to an adjacent word.
|
|
1256
|
+
SCOPE_BOUNDARY_TOKENS = %w[| || && ; $( ( ) if elif then else fi do done while until case esac].freeze
|
|
1257
|
+
|
|
1258
|
+
# Flags that change what a pattern matches and this check actually replays
|
|
1259
|
+
# verbatim against the real grep binary — mode (E/F/G/P) and w, both
|
|
1260
|
+
# threaded through to #whole_repo_grep_matches below — vs flags that only
|
|
1261
|
+
# change what's printed (irrelevant to a files-with-matches re-run). A flag
|
|
1262
|
+
# belongs here only once something below replays it.
|
|
1263
|
+
SCOPE_MATCH_FLAGS = %w[E F G P w].freeze
|
|
1264
|
+
SCOPE_NOOP_FLAGS = %w[c i n o q r l z H h].freeze
|
|
1265
|
+
|
|
1266
|
+
# Recognized flags git grep has no equivalent for (-w and -x are not
|
|
1267
|
+
# symmetric) — declined as not-analyzable, reason naming the flag, rather
|
|
1268
|
+
# than replayed wrong or silently dropped.
|
|
1269
|
+
SCOPE_DECLINED_FLAGS = { "x" => "-x (whole-line match) has no git-grep equivalent" }.freeze
|
|
1270
|
+
|
|
1271
|
+
# git grep, not a raw recursive grep: it skips .git's own object store (a
|
|
1272
|
+
# raw `grep -r .` would trawl it) and matches "the tree" the way the rest
|
|
1273
|
+
# of this corpus's git-based gates already do (diff-scope's own gates are
|
|
1274
|
+
# git diff, not find/grep). Small fixed budget — runs once per recognized
|
|
1275
|
+
# invocation, not per gate.
|
|
1276
|
+
SCOPE_SEARCH_TIMEOUT = 30
|
|
1277
|
+
|
|
1278
|
+
# I12/AC3: rehearse's scope-asymmetry check. I11 shipped a bug its own new
|
|
1279
|
+
# boundary discipline could not catch: a gate's grep-family search covered
|
|
1280
|
+
# only `lib`, while the identifier it renamed also lived in a `test/` file
|
|
1281
|
+
# no lane declared — three consistent statements, all drawn from the same
|
|
1282
|
+
# too-narrow grep. For each gate whose command is a recognizable
|
|
1283
|
+
# file-scoped grep invocation, re-run its own pattern across the whole repo
|
|
1284
|
+
# and report anything that matches outside the gate's own declared paths,
|
|
1285
|
+
# flagging loudest whatever also lies outside every declared lane's touch
|
|
1286
|
+
# set (I12/AC4) — the file no lane may legally fix. touch_globs is that
|
|
1287
|
+
# union; parse_lanes returns [] with no ```lanes``` block, which still
|
|
1288
|
+
# yields the outside-every-lane half of the report. Reports only: never
|
|
1289
|
+
# touches rehearse's exit code, RED/GREEN/BROKEN, or the rehearsal stamp.
|
|
1290
|
+
def scope_asymmetry_report(gates, text, base_dir)
|
|
1291
|
+
touch_globs = parse_lanes(text).flat_map { |l| l["touch"] || [] }
|
|
1292
|
+
findings = []
|
|
1293
|
+
not_analyzable = []
|
|
1294
|
+
|
|
1295
|
+
gates.each do |gate|
|
|
1296
|
+
g = gate.transform_keys(&:to_s)
|
|
1297
|
+
grep_invocations(g["cmd"].to_s).each do |inv|
|
|
1298
|
+
if inv[:status] != :recognized
|
|
1299
|
+
not_analyzable << { id: g["id"], reason: inv[:reason] }
|
|
1300
|
+
next
|
|
1301
|
+
end
|
|
1302
|
+
|
|
1303
|
+
finding = scope_finding(inv, base_dir, touch_globs)
|
|
1304
|
+
if finding
|
|
1305
|
+
findings << finding.merge(id: g["id"])
|
|
1306
|
+
else
|
|
1307
|
+
not_analyzable << { id: g["id"], reason: "whole-repo re-run failed" }
|
|
1308
|
+
end
|
|
1309
|
+
end
|
|
1310
|
+
end
|
|
1311
|
+
|
|
1312
|
+
{ findings: findings, not_analyzable: not_analyzable }
|
|
1313
|
+
end
|
|
1314
|
+
|
|
1315
|
+
# Re-runs a recognized invocation's own pattern across the whole repo and
|
|
1316
|
+
# splits what it finds into: the gate's own declared scope (expected —
|
|
1317
|
+
# that's what the gate already searches, so it's not reported), elsewhere
|
|
1318
|
+
# but inside some lane's touch set, and elsewhere outside every lane's
|
|
1319
|
+
# touch set. nil signals the re-run itself failed (caller counts it as
|
|
1320
|
+
# not-analyzable rather than reporting a guess).
|
|
1321
|
+
def scope_finding(inv, base_dir, touch_globs)
|
|
1322
|
+
matched = whole_repo_grep_matches(inv, base_dir)
|
|
1323
|
+
return nil unless matched
|
|
1324
|
+
|
|
1325
|
+
elsewhere = matched.reject { |f| within_declared_scope?(f, inv[:paths], base_dir) }
|
|
1326
|
+
within_lanes, outside_lanes = elsewhere.partition { |f| in_touch_set?(f, touch_globs) }
|
|
1327
|
+
{ pattern: inv[:pattern], paths: inv[:paths], outside_lanes: outside_lanes.sort, within_lanes: within_lanes.sort }
|
|
1328
|
+
end
|
|
1329
|
+
|
|
1330
|
+
# The files (relative paths) an invocation's own pattern matches anywhere
|
|
1331
|
+
# in the repo, via the real git-grep binary with the same
|
|
1332
|
+
# matching-relevant flags — never a Ruby reimplementation of grep's
|
|
1333
|
+
# pattern semantics. nil on anything other than a clean "matched"/"no
|
|
1334
|
+
# matches" outcome (exit 0/1); [] is a real, examined zero.
|
|
1335
|
+
def whole_repo_grep_matches(inv, base_dir)
|
|
1336
|
+
flags = ["-l", "-I"]
|
|
1337
|
+
flags << "-i" if inv[:case_insensitive]
|
|
1338
|
+
flags << "-w" if inv[:word_boundary]
|
|
1339
|
+
flags << "-#{inv[:mode]}" unless inv[:mode] == "G"
|
|
1340
|
+
cmd = "git grep #{flags.join(' ')} -- #{Shellwords.escape(inv[:pattern])}"
|
|
1341
|
+
captured = capture_with_timeout(cmd, dir: base_dir, timeout: SCOPE_SEARCH_TIMEOUT)
|
|
1342
|
+
return [] if captured[:exit_code] == 1
|
|
1343
|
+
return nil if captured[:timed_out] || captured[:exit_code] != 0
|
|
1344
|
+
|
|
1345
|
+
captured[:stdout].each_line.map(&:chomp).reject(&:empty?)
|
|
1346
|
+
end
|
|
1347
|
+
|
|
1348
|
+
# Is relative_path inside one of an invocation's own declared search
|
|
1349
|
+
# paths? A declared path that's a real directory at rehearsal time covers
|
|
1350
|
+
# anything under it; a file (or a path a lane hasn't written yet, same
|
|
1351
|
+
# BROKEN-adjacent case #classify_rehearsal already names) covers only
|
|
1352
|
+
# itself.
|
|
1353
|
+
def within_declared_scope?(relative_path, declared_paths, base_dir)
|
|
1354
|
+
declared_paths.any? do |p|
|
|
1355
|
+
p = p.sub(%r{/\z}, "")
|
|
1356
|
+
relative_path == p || (base_dir.join(p).directory? && relative_path.start_with?("#{p}/"))
|
|
1357
|
+
end
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
# Every grep-family invocation recognized in one gate's shell command. A
|
|
1361
|
+
# command that never mentions grep/egrep/fgrep isn't this check's concern
|
|
1362
|
+
# at all (silently absent, same as e.g. `bundle exec rake test`) — only a
|
|
1363
|
+
# command that DOES attempt one and can't be cleanly recognized is
|
|
1364
|
+
# reported not-analyzable (I12/AC4's "counted and identifiable").
|
|
1365
|
+
def grep_invocations(cmd)
|
|
1366
|
+
return [] unless cmd.match?(/\b(?:#{SCOPE_GREP_BINARIES.join('|')})\b/)
|
|
1367
|
+
|
|
1368
|
+
segments = shell_segments(cmd)
|
|
1369
|
+
return [{ status: :not_analyzable, reason: "unparseable command" }] unless segments
|
|
1370
|
+
|
|
1371
|
+
found = segments.filter_map { |seg| grep_invocation(seg) }
|
|
1372
|
+
return [{ status: :not_analyzable, reason: "could not locate a recognizable grep invocation" }] if found.empty?
|
|
1373
|
+
|
|
1374
|
+
found
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
# Splits a gate's shell command into simple-command segments at pipes,
|
|
1378
|
+
# `&&`/`||`/`;`, command substitution, and control keywords (see
|
|
1379
|
+
# SCOPE_BOUNDARY_TOKENS). Returns nil on anything Shellwords itself can't
|
|
1380
|
+
# tokenize (an unmatched quote), so the caller counts it instead of
|
|
1381
|
+
# guessing at it.
|
|
1382
|
+
def shell_segments(cmd)
|
|
1383
|
+
tokens = expand_quoted_substitutions(shell_tokenize(cmd))
|
|
1384
|
+
segments = [[]]
|
|
1385
|
+
tokens.each do |tok|
|
|
1386
|
+
if SCOPE_BOUNDARY_TOKENS.include?(tok)
|
|
1387
|
+
segments << []
|
|
1388
|
+
else
|
|
1389
|
+
segments.last << tok
|
|
1390
|
+
end
|
|
1391
|
+
end
|
|
1392
|
+
segments.reject(&:empty?)
|
|
1393
|
+
rescue ArgumentError
|
|
1394
|
+
nil
|
|
1395
|
+
end
|
|
1396
|
+
|
|
1397
|
+
def shell_tokenize(cmd)
|
|
1398
|
+
Shellwords.split(pad_shell_operators(cmd))
|
|
1399
|
+
end
|
|
1400
|
+
|
|
1401
|
+
# Shellwords treats shell operator/substitution punctuation as ordinary
|
|
1402
|
+
# word characters whenever it isn't whitespace-separated (its own docs
|
|
1403
|
+
# warn this isn't a command-line parser) — pads whitespace around
|
|
1404
|
+
# `$(` `&&` `||` `;` `|` `(` `)` and folds a bare newline to `;` (its own
|
|
1405
|
+
# statement terminator, otherwise invisible to Shellwords as anything but
|
|
1406
|
+
# whitespace) so each always surfaces as its own token. Quoted spans and
|
|
1407
|
+
# backslash-escapes are passed through untouched, so a pattern's own `|`
|
|
1408
|
+
# or `$` — inside a quote — is never mistaken for shell syntax.
|
|
1409
|
+
def pad_shell_operators(text)
|
|
1410
|
+
out = String.new
|
|
1411
|
+
text.scan(/'[^']*'|"(?:[^"\\]|\\.)*"|\\.|[^'"\\]+/m) do |chunk|
|
|
1412
|
+
if chunk.start_with?("'", '"', "\\")
|
|
1413
|
+
out << chunk
|
|
1414
|
+
else
|
|
1415
|
+
out << chunk.gsub(/\$\(|&&|\|\||[();|\n]/) { |op| op == "\n" ? " ; " : " #{op} " }
|
|
1416
|
+
end
|
|
1417
|
+
end
|
|
1418
|
+
out
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
# A token that is ITSELF a whole `$(...)` survives #pad_shell_operators
|
|
1422
|
+
# intact only when the substitution sat inside a still-open quote
|
|
1423
|
+
# (protected, so nothing inside it got space-padded) — recurse into its
|
|
1424
|
+
# contents exactly as the top-level command was tokenized.
|
|
1425
|
+
def expand_quoted_substitutions(tokens)
|
|
1426
|
+
tokens.flat_map do |tok|
|
|
1427
|
+
m = tok.match(/\A\$\((.*)\)\z/m)
|
|
1428
|
+
next [tok] unless m
|
|
1429
|
+
|
|
1430
|
+
["$("] + expand_quoted_substitutions(shell_tokenize(m[1])) + [")"]
|
|
1431
|
+
end
|
|
1432
|
+
end
|
|
1433
|
+
|
|
1434
|
+
# Recognizes one simple-command segment as a file-scoped grep-family
|
|
1435
|
+
# invocation, or declines with a specific reason. Never guesses: `-v`
|
|
1436
|
+
# inverts per-line matching (fine for filtering a computed list, wrong for
|
|
1437
|
+
# "does this pattern occur elsewhere") so it's declined rather than
|
|
1438
|
+
# silently reinterpreted; so is any unsupported flag or a path operand
|
|
1439
|
+
# that still carries an unresolved shell variable.
|
|
1440
|
+
def grep_invocation(segment)
|
|
1441
|
+
i = 0
|
|
1442
|
+
i += 1 while segment[i] == "!"
|
|
1443
|
+
return nil unless segment[i] && SCOPE_GREP_BINARIES.include?(segment[i])
|
|
1444
|
+
i += 1
|
|
1445
|
+
|
|
1446
|
+
flag_chars = []
|
|
1447
|
+
loop do
|
|
1448
|
+
tok = segment[i]
|
|
1449
|
+
break unless tok
|
|
1450
|
+
if tok == "--"
|
|
1451
|
+
i += 1
|
|
1452
|
+
break
|
|
1453
|
+
elsif tok.start_with?("-") && tok != "-"
|
|
1454
|
+
flag_chars.concat(tok.delete_prefix("-").chars)
|
|
1455
|
+
i += 1
|
|
1456
|
+
else
|
|
1457
|
+
break
|
|
1458
|
+
end
|
|
1459
|
+
end
|
|
1460
|
+
|
|
1461
|
+
unsupported = flag_chars - (SCOPE_MATCH_FLAGS + SCOPE_NOOP_FLAGS + SCOPE_DECLINED_FLAGS.keys)
|
|
1462
|
+
return { status: :not_analyzable, reason: "unsupported flag(s): #{unsupported.uniq.join(', ')}" } if unsupported.any?
|
|
1463
|
+
return { status: :not_analyzable, reason: "-v (inverted match) semantics not safely reinterpreted" } if flag_chars.include?("v")
|
|
1464
|
+
|
|
1465
|
+
declined = (flag_chars & SCOPE_DECLINED_FLAGS.keys).uniq
|
|
1466
|
+
return { status: :not_analyzable, reason: declined.map { |f| SCOPE_DECLINED_FLAGS[f] }.join("; ") } if declined.any?
|
|
1467
|
+
|
|
1468
|
+
modes = (flag_chars & %w[E F G P]).uniq
|
|
1469
|
+
return { status: :not_analyzable, reason: "ambiguous pattern flags: #{modes.join(', ')}" } if modes.size > 1
|
|
1470
|
+
return { status: :not_analyzable, reason: "no pattern operand" } unless segment[i]
|
|
1471
|
+
|
|
1472
|
+
pattern = segment[i]
|
|
1473
|
+
paths = segment[(i + 1)..] || []
|
|
1474
|
+
return { status: :not_analyzable, reason: "no path operand (pipeline/stdin search)" } if paths.empty?
|
|
1475
|
+
return { status: :not_analyzable, reason: "path operand contains an unresolved shell variable" } if paths.any? { |p| p.include?("$") }
|
|
1476
|
+
|
|
1477
|
+
{ status: :recognized, pattern: pattern, paths: paths, case_insensitive: flag_chars.include?("i"),
|
|
1478
|
+
word_boundary: flag_chars.include?("w"), mode: modes.first || "G" }
|
|
1479
|
+
end
|
|
1480
|
+
|
|
1075
1481
|
def iteration_id(entry)
|
|
1076
1482
|
"I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
|
|
1077
1483
|
end
|
|
@@ -1298,18 +1704,40 @@ module Space::Architect
|
|
|
1298
1704
|
i += 1
|
|
1299
1705
|
changed << orig if orig && !orig.empty?
|
|
1300
1706
|
end
|
|
1301
|
-
|
|
1302
|
-
changed.all? do |f|
|
|
1303
|
-
touch_set.any? do |g|
|
|
1304
|
-
File.fnmatch(g, f, fnm) ||
|
|
1305
|
-
(g.end_with?("/**") && File.fnmatch("#{g}/*", f, fnm))
|
|
1306
|
-
end
|
|
1307
|
-
end
|
|
1707
|
+
changed.all? { |f| in_touch_set?(f, touch_set) }
|
|
1308
1708
|
end
|
|
1309
1709
|
|
|
1310
1710
|
checks
|
|
1311
1711
|
end
|
|
1312
1712
|
|
|
1713
|
+
# Is a changed path inside a lane's declared touch set? Single-sourced so the
|
|
1714
|
+
# in-bounds check (d) and merge_lane!'s conflict classification can never drift.
|
|
1715
|
+
def in_touch_set?(path, globs)
|
|
1716
|
+
globs.any? { |g| Space::Core::Paths.touch_match?(g, path) }
|
|
1717
|
+
end
|
|
1718
|
+
|
|
1719
|
+
# Merge a lane's verbatim block into the current "## Builder Report" body for
|
|
1720
|
+
# #transcribe_evidence!: replace its own "### <lane>" subsection in place if
|
|
1721
|
+
# present (preserving the order lanes were already transcribed in), else append
|
|
1722
|
+
# a new one after the others. Subsection boundaries are matched against the
|
|
1723
|
+
# iteration's declared lane names (like KNOWN_HEADINGS for top-level sections),
|
|
1724
|
+
# not any "### " line, so a verbatim report containing its own "### " heading
|
|
1725
|
+
# can't fool the parser.
|
|
1726
|
+
def replace_lane_report(body, lane, raw, lane_names)
|
|
1727
|
+
block = "### #{lane}\n\n#{raw}"
|
|
1728
|
+
return block if placeholder_body?(body)
|
|
1729
|
+
|
|
1730
|
+
headings = lane_names.map { |n| "### #{n}" }
|
|
1731
|
+
lines = body.lines
|
|
1732
|
+
start = lines.index { |l| l.chomp == "### #{lane}" }
|
|
1733
|
+
return "#{body.strip}\n\n#{block}" unless start
|
|
1734
|
+
|
|
1735
|
+
finish = ((start + 1)...lines.length).find { |i| headings.include?(lines[i].chomp) } || lines.length
|
|
1736
|
+
before = lines[0...start].join.strip
|
|
1737
|
+
after = lines[finish..].join.strip
|
|
1738
|
+
[before, block, after].reject(&:empty?).join("\n\n")
|
|
1739
|
+
end
|
|
1740
|
+
|
|
1313
1741
|
# Replace (or, with append:, extend) the body of a "## Heading" section, leaving
|
|
1314
1742
|
# every other section byte-untouched. Append replaces a placeholder body (only a
|
|
1315
1743
|
# template comment) the first time, then stacks subsections after it. A supplied
|
|
@@ -1356,18 +1784,58 @@ module Space::Architect
|
|
|
1356
1784
|
lines[(start + 1)...finish].join.strip
|
|
1357
1785
|
end
|
|
1358
1786
|
|
|
1787
|
+
# The raw (unparsed) text inside the fenced ```gates block, or nil when the
|
|
1788
|
+
# block itself is absent. Single-sourced so #parse_gates and the rehearsal
|
|
1789
|
+
# stamp's content digest (#gates_digest) can never read two different
|
|
1790
|
+
# slices of the same section.
|
|
1791
|
+
def gates_block_source(text)
|
|
1792
|
+
body = section_body(text, "## Acceptance Criteria")
|
|
1793
|
+
return nil unless body
|
|
1794
|
+
match = body.match(/^```gates\n(.*?)^```/m)
|
|
1795
|
+
match && match[1]
|
|
1796
|
+
end
|
|
1797
|
+
|
|
1359
1798
|
# Extract and parse the fenced ```gates block from the Acceptance Criteria section.
|
|
1360
1799
|
# Returns an array of gate hashes (string-keyed). Returns [] when the block is
|
|
1361
1800
|
# absent, empty, or contains only YAML comments.
|
|
1362
1801
|
def parse_gates(text)
|
|
1363
|
-
|
|
1364
|
-
return [] unless
|
|
1365
|
-
|
|
1366
|
-
return [] unless match
|
|
1367
|
-
parsed = YAML.safe_load(match[1], aliases: false)
|
|
1802
|
+
raw = gates_block_source(text)
|
|
1803
|
+
return [] unless raw
|
|
1804
|
+
parsed = YAML.safe_load(raw, aliases: false)
|
|
1368
1805
|
parsed.is_a?(Array) ? parsed : []
|
|
1369
1806
|
end
|
|
1370
1807
|
|
|
1808
|
+
# A stable digest of the gates block's raw content — the key the I09/AC7
|
|
1809
|
+
# rehearsal stamp is validated against. Any byte edit to the fenced block
|
|
1810
|
+
# (add, remove, reorder, reword) changes this and invalidates the stamp.
|
|
1811
|
+
def gates_digest(text)
|
|
1812
|
+
Digest::SHA256.hexdigest(gates_block_source(text).to_s)
|
|
1813
|
+
end
|
|
1814
|
+
|
|
1815
|
+
# #73, narrowly (I09/AC8): true only when the AC section still carries the
|
|
1816
|
+
# scaffold's untouched placeholder AND there is no active gate — a
|
|
1817
|
+
# hand-authored prose-only AC (placeholder replaced, still no gates) is not
|
|
1818
|
+
# this. Call only after lint_gates! has validated the block parses cleanly.
|
|
1819
|
+
def untouched_ac_placeholder?(text)
|
|
1820
|
+
body = section_body(text, "## Acceptance Criteria")
|
|
1821
|
+
return false unless body
|
|
1822
|
+
body.include?(AC1_PLACEHOLDER) && parse_gates(text).empty?
|
|
1823
|
+
end
|
|
1824
|
+
|
|
1825
|
+
# I09/AC7: freeze! refuses without a fresh rehearsal stamp — one whose
|
|
1826
|
+
# gates_digest matches the CURRENT (about-to-be-frozen) gates block. Stale
|
|
1827
|
+
# (gates edited since) or absent (never rehearsed) both refuse identically;
|
|
1828
|
+
# the stamp records that the architect looked, never that gates passed.
|
|
1829
|
+
def ensure_rehearsed!(iteration, entry, text)
|
|
1830
|
+
stamp = entry["rehearsal"]
|
|
1831
|
+
return if stamp && stamp["gates_digest"] == gates_digest(text)
|
|
1832
|
+
|
|
1833
|
+
raise Space::Core::Error,
|
|
1834
|
+
"Iteration '#{iteration}' has not been rehearsed against its current gates — " \
|
|
1835
|
+
"run `architect rehearse #{iteration}` first, or `architect freeze #{iteration} " \
|
|
1836
|
+
"--skip-rehearse REASON` to skip deliberately."
|
|
1837
|
+
end
|
|
1838
|
+
|
|
1371
1839
|
# Extract and parse the fenced ```lanes block from the Specification section.
|
|
1372
1840
|
# Returns an array of lane declaration hashes (string-keyed). Returns [] when the
|
|
1373
1841
|
# block is absent, empty, or contains only YAML comments (back-compat).
|
|
@@ -1420,8 +1888,26 @@ module Space::Architect
|
|
|
1420
1888
|
return
|
|
1421
1889
|
end
|
|
1422
1890
|
result = GateLint.call(gates)
|
|
1423
|
-
|
|
1424
|
-
|
|
1891
|
+
raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}" unless result.success?
|
|
1892
|
+
|
|
1893
|
+
warn_bare_repo_prefix!(gates, warnings) if warnings
|
|
1894
|
+
end
|
|
1895
|
+
|
|
1896
|
+
# I09/AC9(b), #39: a gate whose `cmd` carries a literal `repos/<name>/` prefix
|
|
1897
|
+
# with no `cwd` set is warned about, not failed — `cmd` already resolves
|
|
1898
|
+
# against the repo tree (`cwd` is what's space-root-relative), so this
|
|
1899
|
+
# pattern is usually a leftover space-root-relative path, but is legal and
|
|
1900
|
+
# occasionally correct. Threaded through the same warnings: channel
|
|
1901
|
+
# lint_gates! already carries, never a second channel.
|
|
1902
|
+
def warn_bare_repo_prefix!(gates, warnings)
|
|
1903
|
+
gates.each do |g|
|
|
1904
|
+
g = g.transform_keys(&:to_s)
|
|
1905
|
+
next if g["cwd"]
|
|
1906
|
+
next unless g["cmd"].to_s.match?(BARE_REPO_PREFIX)
|
|
1907
|
+
warnings << "gate '#{g["id"]}': cmd contains a literal 'repos/<name>/' path with no cwd set — " \
|
|
1908
|
+
"cmd already resolves against the repo tree, so this is likely a leftover space-root-relative " \
|
|
1909
|
+
"path (legal, occasionally correct — verify it)"
|
|
1910
|
+
end
|
|
1425
1911
|
end
|
|
1426
1912
|
|
|
1427
1913
|
# Raises if any lane in the entry has been dispatched (dispatched_at or integrate_sha set).
|