space-architect 5.5.1 → 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 +110 -0
- data/lib/space_architect/architect_project.rb +534 -57
- 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,12 +44,22 @@ module Space::Architect
|
|
|
42
44
|
# Hard per-gate timeout. Generous relative to the full suite (~55s).
|
|
43
45
|
DEFAULT_GATE_TIMEOUT = 900
|
|
44
46
|
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
|
|
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'"]+/}
|
|
51
63
|
|
|
52
64
|
# Legacy sentinel: worktree_add used to seed prompt.md with this placeholder
|
|
53
65
|
# (dropped — the blind-overwrite tripped harness read-before-write guards, #48).
|
|
@@ -153,6 +165,7 @@ module Space::Architect
|
|
|
153
165
|
block = space.data["project"] || {}
|
|
154
166
|
architecture_dir = space.path.join("architecture")
|
|
155
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
|
|
156
169
|
architecture_dir.children
|
|
157
170
|
.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
|
|
158
171
|
.map { |f| f.basename.to_s }.sort
|
|
@@ -166,7 +179,7 @@ module Space::Architect
|
|
|
166
179
|
# any pending changes to the iteration file and records HEAD as freeze_sha. If
|
|
167
180
|
# already frozen, refuses when the frozen region has changed since.
|
|
168
181
|
# With force: true, re-freezes a changed frozen region if no lane is dispatched yet.
|
|
169
|
-
def freeze!(iteration, warnings: nil, message: nil, force: false)
|
|
182
|
+
def freeze!(iteration, warnings: nil, message: nil, force: false, skip_rehearse_reason: nil)
|
|
170
183
|
entry = slice_entry(iteration)
|
|
171
184
|
rel = entry["file"]
|
|
172
185
|
path = space.path.join(rel)
|
|
@@ -179,6 +192,19 @@ module Space::Architect
|
|
|
179
192
|
lint_gates!(text, warnings: warnings)
|
|
180
193
|
lint_lanes!(text)
|
|
181
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
|
+
|
|
182
208
|
if entry["freeze_sha"]
|
|
183
209
|
sha = entry["freeze_sha"]
|
|
184
210
|
if frozen_region_changed?(sha, rel)
|
|
@@ -211,6 +237,7 @@ module Space::Architect
|
|
|
211
237
|
next unless s["name"] == iteration
|
|
212
238
|
s["freeze_sha"] = sha
|
|
213
239
|
s["verdict"] ||= "pending"
|
|
240
|
+
s["rehearsal_skip_reason"] = skip_rehearse_reason.strip if skip_rehearse_reason
|
|
214
241
|
lanes = s["lanes"] || []
|
|
215
242
|
declared.each do |d|
|
|
216
243
|
fields = { "name" => d["name"], "repo" => d["repo"], "touch_set" => Array(d["touch"]) }
|
|
@@ -321,6 +348,9 @@ module Space::Architect
|
|
|
321
348
|
|
|
322
349
|
# Transcribe a lane's scratch report (build/<id>[-<lane>]/report.md) VERBATIM into
|
|
323
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.
|
|
324
354
|
def transcribe_evidence!(iteration, lane: nil, message: nil)
|
|
325
355
|
entry = slice_entry(iteration)
|
|
326
356
|
rel = entry["file"]
|
|
@@ -333,8 +363,15 @@ module Space::Architect
|
|
|
333
363
|
raw = report.read
|
|
334
364
|
raise Space::Core::Error, "builder report is empty: #{report}" if raw.strip.empty?
|
|
335
365
|
|
|
336
|
-
|
|
337
|
-
|
|
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))
|
|
338
375
|
|
|
339
376
|
nn = format("%02d", entry["ordinal"] || 0)
|
|
340
377
|
git_capture("-C", space.path.to_s, "commit", "-m",
|
|
@@ -366,7 +403,20 @@ module Space::Architect
|
|
|
366
403
|
# the lane branch, then merge --no-ff into the repo's lane/<id> integration branch.
|
|
367
404
|
# Runs NO gates and makes NO pass/fail decision. Refuses a mechanically-failing lane
|
|
368
405
|
# (builder commits / out-of-bounds) and aborts cleanly on a merge conflict.
|
|
369
|
-
|
|
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
|
+
|
|
370
420
|
entry = slice_entry(iteration)
|
|
371
421
|
lane_entry = (entry["lanes"] || []).find { |l| l["name"] == lane }
|
|
372
422
|
raise Space::Core::Error, "No lane '#{lane}' recorded for iteration '#{iteration}'" unless lane_entry
|
|
@@ -376,8 +426,10 @@ module Space::Architect
|
|
|
376
426
|
if checks[:no_builder_commits] == false
|
|
377
427
|
raise Space::Core::Error, "Lane '#{lane}' has builder commits — the worktree is tampered (hard rule 7). Reset and re-dispatch; do not merge."
|
|
378
428
|
end
|
|
379
|
-
if checks[:in_bounds] == false
|
|
380
|
-
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."
|
|
381
433
|
end
|
|
382
434
|
|
|
383
435
|
repo = lane_entry["repo"]
|
|
@@ -427,6 +479,8 @@ module Space::Architect
|
|
|
427
479
|
merge_sha, = git_capture("-C", repo_path.to_s, "rev-parse", "HEAD")
|
|
428
480
|
diffstat, = git_capture("-C", repo_path.to_s, "diff", "--stat", "#{base_sha}..HEAD")
|
|
429
481
|
|
|
482
|
+
bounds_override_reason = accept_bounds_reason.strip if accept_bounds_reason && checks[:in_bounds] == false
|
|
483
|
+
|
|
430
484
|
update_architect_block do |b|
|
|
431
485
|
b["integration_branch"] = integration_branch
|
|
432
486
|
(b["iterations"] || []).each do |s|
|
|
@@ -435,27 +489,36 @@ module Space::Architect
|
|
|
435
489
|
next unless l["name"] == lane
|
|
436
490
|
l["integration_branch"] = integration_branch
|
|
437
491
|
l["integrate_sha"] = integrate_sha
|
|
492
|
+
l["bounds_override_reason"] = bounds_override_reason if bounds_override_reason
|
|
438
493
|
end
|
|
439
494
|
end
|
|
440
495
|
b
|
|
441
496
|
end
|
|
442
497
|
|
|
443
|
-
{ lane: lane, repo: repo, integration_branch: integration_branch,
|
|
444
|
-
|
|
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 }
|
|
445
501
|
end
|
|
446
502
|
|
|
447
503
|
# Loop merge_lane! over the architect-supplied passing set, in order. Stops on the
|
|
448
504
|
# first conflict (a disjointness defect). Never decides which lanes pass. With no
|
|
449
505
|
# lanes and teardown: true, tears down every lane recorded for the iteration instead
|
|
450
506
|
# (the second, teardown-only call in the loop's integrate-then-teardown rhythm).
|
|
451
|
-
|
|
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)
|
|
452
514
|
lanes = Array(lanes)
|
|
453
515
|
return teardown_lanes!(iteration, slice_entry(iteration)["lanes"] || []) if lanes.empty? && teardown
|
|
454
516
|
raise Space::Core::Error, "No lanes given to integrate" if lanes.empty?
|
|
455
517
|
|
|
456
518
|
merged = []
|
|
457
519
|
lanes.each do |lane|
|
|
458
|
-
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)
|
|
459
522
|
rescue Space::Core::Error => e
|
|
460
523
|
done = merged.map { |m| m[:lane] }.join(", ")
|
|
461
524
|
raise Space::Core::Error, "Integrated #{done.empty? ? "(none)" : done} then stopped at '#{lane}': #{e.message}"
|
|
@@ -463,6 +526,8 @@ module Space::Architect
|
|
|
463
526
|
|
|
464
527
|
teardown_lanes!(iteration, merged) if teardown
|
|
465
528
|
merged
|
|
529
|
+
ensure
|
|
530
|
+
commit_metadata_mutation!(iteration, message: message)
|
|
466
531
|
end
|
|
467
532
|
|
|
468
533
|
# Run the iteration's frozen Acceptance Criteria gate commands. Each gate is
|
|
@@ -470,7 +535,10 @@ module Space::Architect
|
|
|
470
535
|
# a hard timeout, and evaluated against its `expect` block. Returns an array
|
|
471
536
|
# of result hashes with :status (:pass/:fail) and :reason in addition to the
|
|
472
537
|
# raw :stdout/:stderr/:exit_code. The mechanical verdict belongs here; the AC
|
|
473
|
-
# 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).
|
|
474
542
|
def run_gates(iteration, lane: nil)
|
|
475
543
|
entry = slice_entry(iteration)
|
|
476
544
|
freeze_sha = entry["freeze_sha"]
|
|
@@ -498,37 +566,50 @@ module Space::Architect
|
|
|
498
566
|
end
|
|
499
567
|
raise Space::Core::Error, "directory does not exist: #{base_dir}" unless base_dir.exist?
|
|
500
568
|
|
|
501
|
-
gates
|
|
502
|
-
|
|
503
|
-
dir =
|
|
504
|
-
if (cwd = g["cwd"])
|
|
505
|
-
gate_cwd = space.path.join(cwd)
|
|
506
|
-
if lane && repo_root && (gate_cwd == repo_root || gate_cwd.to_s.start_with?("#{repo_root}/"))
|
|
507
|
-
base_dir.join(gate_cwd.relative_path_from(repo_root)).cleanpath
|
|
508
|
-
else
|
|
509
|
-
gate_cwd
|
|
510
|
-
end
|
|
511
|
-
else
|
|
512
|
-
base_dir
|
|
513
|
-
end
|
|
514
|
-
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
|
|
515
571
|
|
|
516
|
-
|
|
517
|
-
|
|
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
|
|
518
588
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
589
|
+
gates = parse_gates(text)
|
|
590
|
+
repo, base_dir, gate_results, scope_report =
|
|
591
|
+
if gates.empty?
|
|
592
|
+
[nil, nil, [], nil]
|
|
522
593
|
else
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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)]
|
|
526
600
|
end
|
|
527
601
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
531
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 }
|
|
532
613
|
end
|
|
533
614
|
|
|
534
615
|
# Emit grounding reads for the architect's SessionStart hook.
|
|
@@ -789,7 +870,7 @@ module Space::Architect
|
|
|
789
870
|
def worktree_list
|
|
790
871
|
wt_base = space.path.join("build")
|
|
791
872
|
return [] unless wt_base.exist?
|
|
792
|
-
wt_base.
|
|
873
|
+
Space::Core::Paths.layout_children(wt_base).select(&:directory?).map { |p| p.basename.to_s }.sort
|
|
793
874
|
end
|
|
794
875
|
|
|
795
876
|
# Materialize the iteration's declared lanes: for each lane (or the one named via
|
|
@@ -1002,6 +1083,21 @@ module Space::Architect
|
|
|
1002
1083
|
body.empty? ? composed : "#{composed}\n\n#{body}"
|
|
1003
1084
|
end
|
|
1004
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
|
+
|
|
1005
1101
|
# Remove each lane's worktree and safe-delete (`-d`) its lane branch. Accepts
|
|
1006
1102
|
# either merge_lane! results (symbol keys) or recorded lane entries (string
|
|
1007
1103
|
# keys) — both carry a lane name and a repo.
|
|
@@ -1035,6 +1131,7 @@ module Space::Architect
|
|
|
1035
1131
|
end
|
|
1036
1132
|
end
|
|
1037
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
|
|
1038
1135
|
candidates = arch_dir.children.select { |f| f.basename.to_s.match?(/\AI\d+-.+\.md\z/) }
|
|
1039
1136
|
return nil if candidates.empty?
|
|
1040
1137
|
candidates.max_by { |f| f.basename.to_s[/\AI(\d+)/, 1].to_i }
|
|
@@ -1046,7 +1143,7 @@ module Space::Architect
|
|
|
1046
1143
|
def capture_with_timeout(cmd, dir:, timeout:)
|
|
1047
1144
|
out_f = Tempfile.new(["gate-stdout", ".log"])
|
|
1048
1145
|
err_f = Tempfile.new(["gate-stderr", ".log"])
|
|
1049
|
-
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)
|
|
1050
1147
|
|
|
1051
1148
|
deadline = Time.now + timeout
|
|
1052
1149
|
status = nil
|
|
@@ -1076,6 +1173,311 @@ module Space::Architect
|
|
|
1076
1173
|
err_f&.close!
|
|
1077
1174
|
end
|
|
1078
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
|
+
|
|
1079
1481
|
def iteration_id(entry)
|
|
1080
1482
|
"I#{format('%02d', entry['ordinal'])}-#{entry['name']}"
|
|
1081
1483
|
end
|
|
@@ -1310,13 +1712,30 @@ module Space::Architect
|
|
|
1310
1712
|
|
|
1311
1713
|
# Is a changed path inside a lane's declared touch set? Single-sourced so the
|
|
1312
1714
|
# in-bounds check (d) and merge_lane!'s conflict classification can never drift.
|
|
1313
|
-
# A trailing `dir/**` is matched twice: bare (PATHNAME stops it at direct
|
|
1314
|
-
# children) and as `dir/**/*`, whose whole-component `**/` does cross `/`.
|
|
1315
1715
|
def in_touch_set?(path, globs)
|
|
1316
|
-
globs.any?
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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")
|
|
1320
1739
|
end
|
|
1321
1740
|
|
|
1322
1741
|
# Replace (or, with append:, extend) the body of a "## Heading" section, leaving
|
|
@@ -1365,18 +1784,58 @@ module Space::Architect
|
|
|
1365
1784
|
lines[(start + 1)...finish].join.strip
|
|
1366
1785
|
end
|
|
1367
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
|
+
|
|
1368
1798
|
# Extract and parse the fenced ```gates block from the Acceptance Criteria section.
|
|
1369
1799
|
# Returns an array of gate hashes (string-keyed). Returns [] when the block is
|
|
1370
1800
|
# absent, empty, or contains only YAML comments.
|
|
1371
1801
|
def parse_gates(text)
|
|
1372
|
-
|
|
1373
|
-
return [] unless
|
|
1374
|
-
|
|
1375
|
-
return [] unless match
|
|
1376
|
-
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)
|
|
1377
1805
|
parsed.is_a?(Array) ? parsed : []
|
|
1378
1806
|
end
|
|
1379
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
|
+
|
|
1380
1839
|
# Extract and parse the fenced ```lanes block from the Specification section.
|
|
1381
1840
|
# Returns an array of lane declaration hashes (string-keyed). Returns [] when the
|
|
1382
1841
|
# block is absent, empty, or contains only YAML comments (back-compat).
|
|
@@ -1429,8 +1888,26 @@ module Space::Architect
|
|
|
1429
1888
|
return
|
|
1430
1889
|
end
|
|
1431
1890
|
result = GateLint.call(gates)
|
|
1432
|
-
|
|
1433
|
-
|
|
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
|
|
1434
1911
|
end
|
|
1435
1912
|
|
|
1436
1913
|
# Raises if any lane in the entry has been dispatched (dispatched_at or integrate_sha set).
|