Skip to content

Commit 4af8f0d

Browse files
committed
Merge remote-tracking branch 'origin/main' into jg/rsc-nextjs-turbopack-comparison-doc
* origin/main: Bump version to 17.0.0.rc.6 docs: fix dead undici link and silence flaky GitHub links in lychee check (#4165) Update changelog for 17.0.0.rc.6 (#4174) Reduce PR security preflight false positives for CI metadata (#4170) [codex] Document targeted agent coordination reads (#4166) Carry forward seam doctor follow-up fixes (#4171) Unskip Rspack RSC e2e tests on react-on-rails-rsc 19.2 (rc.3) (#4173) Revert "docs: add accessibility best-practices guide (RoR + RSC) (#4086)" docs: rsc-css render-blocking links + end-of-head cascade trap (#4138) (#4143) Fix RSC-safe locale default messages output (#4146) Share agent workflows via installed skills + repo seam (#4121) Codify degraded agent-coord batch fallback (#4161) [codex] Document flagship demo coordination (#4164) Enrich deferred-render RSC errors with the bundle diagnostic (#3475) (#4100)
2 parents d6601b9 + 5290f17 commit 4af8f0d

76 files changed

Lines changed: 4927 additions & 1999 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
# Validate that portable agent workflows can resolve their repo-specific seam.
5+
6+
require "json"
7+
require "optparse"
8+
9+
# rubocop:disable Metrics/ModuleLength
10+
module AgentWorkflowSeamDoctor
11+
SECTION = "Agent Workflow Configuration"
12+
SECTION_HEADING = /^##\s+#{Regexp.escape(SECTION)}\s*$/
13+
REQUIRED_KEYS = [
14+
"Base branch",
15+
"Pre-push local validation",
16+
"CI change detector",
17+
"Hosted-CI trigger",
18+
"Benchmark labels",
19+
"Follow-up issue prefix",
20+
"Changelog",
21+
"Lint / format",
22+
"Merge ledger",
23+
"Docs checks",
24+
"Tests",
25+
"Build / type checks",
26+
"Review gate",
27+
"Approval-exempt change categories",
28+
"Coordination backend"
29+
].freeze
30+
CONFIG_KEY_PATTERN = /^-\s+\*\*(.+?)\*\*:\s*(.*)$/
31+
32+
SEAM_PLACEHOLDER = %r{
33+
<[^>\n]*
34+
(?:
35+
follow-up\ prefix |
36+
hosted[-\ ]CI |
37+
benchmark\ labels? |
38+
changelog\ path |
39+
main\ branch |
40+
base\ branch |
41+
local\ validation |
42+
CI\ change\ detector |
43+
lint\ /\ format |
44+
docs\ checks |
45+
unit\ command |
46+
integration\ command |
47+
e2e\ command |
48+
tests?\ commands? |
49+
unit\ /\ integration\ /\ e2e\ commands |
50+
build\ /\ type\ checks? |
51+
merge\ ledger |
52+
review\ gate |
53+
approval[-\ ]exempt\ change\ categor(?:y|ies) |
54+
coordination\ backend
55+
)
56+
[^>\n]*>
57+
}ix
58+
EXECUTABLE_FENCE_LANGS = ["", "bash", "sh", "shell", "zsh", "ruby", "python", "js", "javascript"].freeze
59+
COMMAND_LIKE = %r{
60+
(?:\b(?:bash|bundle|codex|gh|git|node|npm|pnpm|rake|ruby|script|sh|yarn)\b) |
61+
(?:\b(?:bin|script|\.agents)/)
62+
}x
63+
64+
module_function
65+
66+
def check(root, shared_roots: [])
67+
issues = []
68+
agents_path = File.join(root, "AGENTS.md")
69+
70+
return ["missing AGENTS.md"] unless File.file?(agents_path)
71+
72+
config = parse_config(File.read(agents_path))
73+
if config.nil?
74+
issues << "missing AGENTS.md section: #{SECTION}"
75+
else
76+
issues.concat(missing_key_issues(config))
77+
end
78+
79+
issues.concat(shared_root_issues(shared_roots))
80+
81+
shared_markdown_paths(root, shared_roots:).each do |path|
82+
issues.concat(executable_placeholder_issues(root, path))
83+
end
84+
85+
issues
86+
end
87+
88+
def parse_config(text)
89+
section = extract_section(text)
90+
return nil if section.nil?
91+
92+
config = {}
93+
current_key = nil
94+
95+
section.each_line do |line|
96+
if (match = line.match(CONFIG_KEY_PATTERN))
97+
current_key = match[1].strip
98+
config[current_key] = match[2].strip
99+
next
100+
end
101+
102+
if config_continuation?(line, current_key)
103+
config[current_key] = [config[current_key], line.strip].reject(&:empty?).join(" ")
104+
elsif config_key_finished?(line)
105+
current_key = nil
106+
end
107+
end
108+
109+
config
110+
end
111+
112+
def config_continuation?(line, current_key)
113+
current_key && line.match?(/^\s{2,}\S/) && !line.match?(CONFIG_KEY_PATTERN)
114+
end
115+
116+
def config_key_finished?(line)
117+
line.strip.empty? || !line.match?(/^\s{2,}\S/)
118+
end
119+
120+
def extract_section(text)
121+
lines = text.lines
122+
start_index = lines.index { |line| line.match?(SECTION_HEADING) }
123+
return nil if start_index.nil?
124+
125+
body = []
126+
lines[(start_index + 1)..].each do |line|
127+
break if line.match?(/^##\s+/)
128+
129+
body << line
130+
end
131+
body.join
132+
end
133+
134+
def missing_key_issues(config)
135+
REQUIRED_KEYS.filter_map do |key|
136+
value = config[key]
137+
if value.nil?
138+
"missing #{SECTION} key: #{key}"
139+
elsif unresolved_template_value?(value)
140+
"unresolved #{SECTION} value for key: #{key}"
141+
end
142+
end
143+
end
144+
145+
def unresolved_template_value?(value)
146+
stripped = value.strip
147+
stripped.empty? || stripped.match?(SEAM_PLACEHOLDER)
148+
end
149+
150+
def shared_markdown_paths(root, shared_roots: [])
151+
repo_patterns = [
152+
File.join(root, ".agents/skills/**/*.md"),
153+
File.join(root, ".agents/workflows/**/*.md")
154+
]
155+
shared_patterns = shared_roots.flat_map { |shared_root| shared_markdown_patterns(shared_root) }
156+
157+
(repo_patterns + shared_patterns).flat_map { |pattern| Dir.glob(pattern) }.map do |path|
158+
File.expand_path(path)
159+
end.uniq.sort
160+
end
161+
162+
def shared_root_issues(shared_roots)
163+
shared_roots.filter_map do |shared_root|
164+
expanded = File.expand_path(shared_root)
165+
if !File.directory?(expanded)
166+
"missing shared root: #{expanded}"
167+
elsif shared_markdown_paths_for_root(expanded).empty?
168+
"shared root has no skill/workflow Markdown: #{expanded}"
169+
end
170+
end
171+
end
172+
173+
def shared_markdown_paths_for_root(shared_root)
174+
shared_markdown_patterns(shared_root).flat_map { |pattern| Dir.glob(pattern) }.uniq.sort
175+
end
176+
177+
def shared_markdown_patterns(shared_root)
178+
expanded = File.expand_path(shared_root)
179+
[
180+
File.join(expanded, "skills/**/*.md"),
181+
File.join(expanded, "workflows/**/*.md"),
182+
# Also support passing an installed skill root directly, without a parent skills/ directory.
183+
File.join(expanded, "**/SKILL.md")
184+
]
185+
end
186+
187+
def executable_placeholder_issues(root, path)
188+
issues = []
189+
in_fence = false
190+
executable_fence = false
191+
fence_delimiter = nil
192+
193+
File.binread(path).force_encoding("UTF-8").scrub.each_line.with_index do |line, index|
194+
line_number = index + 1
195+
fence_state = update_fence_state(line, in_fence, fence_delimiter)
196+
if fence_state
197+
in_fence, executable_fence, fence_delimiter = fence_state
198+
next
199+
end
200+
201+
placeholders = placeholders_from_line(line, in_fence:, executable_fence:)
202+
placeholders.each { |placeholder| issues << placeholder_issue(root, path, line_number, placeholder) }
203+
end
204+
205+
issues
206+
end
207+
208+
def update_fence_state(line, in_fence, fence_delimiter)
209+
line = line.chomp
210+
211+
if in_fence
212+
closing_fence = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/)
213+
return nil if closing_fence.nil?
214+
215+
delimiter = closing_fence[1]
216+
# Mismatched closers stay inside the current fence.
217+
return nil unless closing_fence_delimiter?(delimiter, fence_delimiter)
218+
219+
return [false, false, nil]
220+
end
221+
222+
fence = line.match(/^ {0,3}(`{3,}|~{3,})(?:[ \t]*([[:alnum:]_-]+))?(?:[ \t].*)?$/)
223+
return nil if fence.nil?
224+
225+
delimiter = fence[1]
226+
language = fence[2].to_s.downcase
227+
[true, EXECUTABLE_FENCE_LANGS.include?(language), delimiter]
228+
end
229+
230+
def closing_fence_delimiter?(delimiter, fence_delimiter)
231+
delimiter[0] == fence_delimiter[0] && delimiter.length >= fence_delimiter.length
232+
end
233+
234+
def placeholders_from_line(line, in_fence:, executable_fence:)
235+
placeholders = []
236+
237+
placeholders.concat(line.scan(SEAM_PLACEHOLDER)) if in_fence && executable_fence && executable_line?(line)
238+
239+
inline_code_snippets(line).each do |snippet|
240+
next if in_fence
241+
next unless command_like?(snippet)
242+
243+
placeholders.concat(snippet.scan(SEAM_PLACEHOLDER))
244+
end
245+
246+
placeholders
247+
end
248+
249+
def executable_line?(line)
250+
stripped = line.strip
251+
!stripped.empty? && !stripped.start_with?("#", "//")
252+
end
253+
254+
def inline_code_snippets(line)
255+
line.scan(/`([^`]+)`/).flatten
256+
end
257+
258+
def command_like?(snippet)
259+
snippet.match?(COMMAND_LIKE)
260+
end
261+
262+
def placeholder_issue(root, path, line_number, placeholder)
263+
expanded_root = File.expand_path(root)
264+
location = if path.start_with?("#{expanded_root}/")
265+
path.delete_prefix("#{expanded_root}/")
266+
else
267+
"[shared] #{path}"
268+
end
269+
"unresolved executable placeholder #{placeholder} in #{location}:#{line_number}"
270+
end
271+
272+
def format_text(issues)
273+
if issues.empty?
274+
"PASS agent workflow seam is complete\n"
275+
else
276+
out = +"FAIL agent workflow seam has #{issues.length} issue(s)\n"
277+
issues.each { |issue| out << "- #{issue}\n" }
278+
out
279+
end
280+
end
281+
282+
def format_json(issues)
283+
payload = { "status" => issues.empty? ? "PASS" : "FAIL", "issues" => issues }
284+
"#{JSON.pretty_generate(payload)}\n"
285+
end
286+
end
287+
# rubocop:enable Metrics/ModuleLength
288+
289+
if $PROGRAM_NAME == __FILE__
290+
options = { root: Dir.pwd, json: false, shared_roots: [] }
291+
parser = OptionParser.new do |opts|
292+
opts.banner = "Usage: agent-workflow-seam-doctor [--root DIR] [--shared DIR] [--json]"
293+
opts.on("--root DIR", "Repository root to inspect") { |value| options[:root] = value }
294+
opts.on("--shared DIR", "Installed shared skill/workflow root to scan; repeatable") do |value|
295+
options[:shared_roots] << value
296+
end
297+
opts.on("--json", "Print JSON output") { options[:json] = true }
298+
opts.on("-h", "--help", "Show help") do
299+
puts opts
300+
exit 0
301+
end
302+
end
303+
parser.parse!
304+
305+
root = File.expand_path(options.fetch(:root))
306+
issues = AgentWorkflowSeamDoctor.check(root, shared_roots: options.fetch(:shared_roots))
307+
print(options[:json] ? AgentWorkflowSeamDoctor.format_json(issues) : AgentWorkflowSeamDoctor.format_text(issues))
308+
exit(issues.empty? ? 0 : 1)
309+
end

0 commit comments

Comments
 (0)