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.
@@ -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
- # Flags for matching a changed path against a lane's touch_set globs.
46
- # PATHNAME keeps a single `*` from crossing `/`; EXTGLOB enables `{a,b}`;
47
- # DOTMATCH lets a glob reach dotfile segments, so a `dir/**` touch set covers
48
- # `dir/.github/workflows/ci.yml` the standard deliverable for a lane preparing
49
- # a directory to become a repo root.
50
- TOUCH_FNM = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
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
- block = lane ? "### #{lane}\n\n#{raw.rstrip}" : raw.rstrip
337
- path.write(replace_section_body(path.read, "## Builder Report", block, append: !lane.nil?))
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
- def merge_lane!(iteration, lane, message: nil, commit_mode: nil, into: nil)
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 and re-dispatch."
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
- merge_sha: merge_sha.strip, base_sha: base_sha, diffstat: diffstat.strip, gates_run: false }
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
- def integrate!(iteration, lanes: nil, teardown: false, message: nil, commit_mode: nil, into: nil)
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.map do |gate|
502
- g = gate.transform_keys(&:to_s)
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
- effective = g["timeout"] || DEFAULT_GATE_TIMEOUT
517
- captured = capture_with_timeout(g["cmd"], dir: dir, timeout: effective)
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
- if captured[:timed_out]
520
- status = :fail
521
- reason = "timed out after #{effective}s"
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
- ev = GateEvaluator.call(stdout: captured[:stdout], exit_code: captured[:exit_code], expect: g["expect"] || {})
524
- status = ev.pass? ? :pass : :fail
525
- reason = ev.reason
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
- { id: g["id"], ac: g["ac"].to_s, cmd: g["cmd"], expect: g["expect"],
529
- stdout: captured[:stdout], stderr: captured[:stderr], exit_code: captured[:exit_code],
530
- dir: dir, status: status, reason: reason }
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.children.select(&:directory?).map { |p| p.basename.to_s }.sort
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? do |g|
1317
- File.fnmatch(g, path, TOUCH_FNM) ||
1318
- (g.end_with?("/**") && File.fnmatch("#{g}/*", path, TOUCH_FNM))
1319
- end
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
- body = section_body(text, "## Acceptance Criteria")
1373
- return [] unless body
1374
- match = body.match(/^```gates\n(.*?)^```/m)
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
- return if result.success?
1433
- raise Space::Core::Error, "ill-formed gates block:\n#{result.failure.join("\n")}"
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).