Skip to content

Commit ec44bea

Browse files
authored
ci: enforce architecture checks in pull requests (#526)
* ci: enforce architecture checks in pull requests * refactor(keymap): move mapping engine into UI layer Defer protocol message defaults to messaging service * test: make async waits more reliable Increase timeout windows and wait for explicit conditions in protocol runtime and session tab strip tests.
1 parent e06cbb1 commit ec44bea

21 files changed

Lines changed: 484 additions & 180 deletions

‎.github/workflows/tests.yml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,52 @@ jobs:
4949
- name: Run type checks
5050
run: make typecheck ARGS="-f github"
5151

52+
architecture:
53+
if: github.event_name == 'pull_request'
54+
runs-on: ubuntu-latest
55+
name: architecture check
56+
permissions:
57+
contents: read
58+
pull-requests: write
59+
steps:
60+
- uses: actions/checkout@v4
61+
with:
62+
fetch-depth: 0
63+
64+
- name: Install uv
65+
uses: astral-sh/setup-uv@v6
66+
67+
- name: Check module imports against layer rules
68+
id: check
69+
env:
70+
BASE_REF: ${{ github.base_ref }}
71+
run: |
72+
set +e
73+
make -s topology-diff ARGS="--from origin/$BASE_REF --to HEAD --check --markdown" > architecture-report.md
74+
echo "status=$?" >> "$GITHUB_OUTPUT"
75+
cat architecture-report.md >> "$GITHUB_STEP_SUMMARY"
76+
77+
# Fork PRs get a read-only token; the job summary still carries the report.
78+
- name: Post report on the pull request
79+
continue-on-error: true
80+
env:
81+
GH_TOKEN: ${{ github.token }}
82+
PR: ${{ github.event.pull_request.number }}
83+
run: |
84+
[ -s architecture-report.md ] || exit 0
85+
marker='<!-- architecture-check-report -->'
86+
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \
87+
--jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1)
88+
if [ -n "$id" ]; then
89+
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@architecture-report.md
90+
else
91+
gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@architecture-report.md
92+
fi
93+
94+
- name: Fail on new problems
95+
if: steps.check.outputs.status != '0'
96+
run: exit 1
97+
5298
test:
5399
timeout-minutes: 4
54100
strategy:

‎AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Commands
44

5-
`make help` lists all. Key: `check` (format-check + typecheck + test), `test [TEST=path]`, `typecheck`, `format-check`, `replay`, `topology`, `topology-diff`. Extra flags: `ARGS="..."`.
5+
`make help` lists all. Key: `check` (format-check + typecheck + test), `test [TEST=path]`, `typecheck`, `format-check`, `replay`, `topology`, `topology-diff`, `topology-check`. Extra flags: `ARGS="..."`.
66

77
## Before Finishing Lua Changes (Mandatory)
88

@@ -33,4 +33,5 @@ Use scanner, not grep, for architecture/debt. Remove debt only after code + test
3333

3434
- `make topology`: gap vs target policy.
3535
- `make topology-diff ARGS="--from main --to HEAD"`: improved/regressed/neutral.
36+
- `make topology-check`: CI gate vs `origin/main`; fails on new layer-rule breaks, new/grown import loops, or modules missing from `topology.jsonc`.
3637
- Flags: `--json`, `--snapshot <git-ref>`. Docs: `scripts/dependency-topology/README.md`.

‎Makefile‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,16 @@ PYTHON ?= python3
99

1010
LUA_GLOBS := -g '*.lua' -g '!deps/'
1111
TOPOLOGY := scripts/dependency-topology/scan_topology.py
12+
TOPOLOGY_REQUIREMENTS := scripts/dependency-topology/requirements.txt
13+
# uv fetches the scanner's Python deps on demand; without uv, install them into $(PYTHON) first.
14+
TOPOLOGY_PYTHON ?= $(if $(shell command -v uv),uv run --quiet --no-project --with-requirements $(TOPOLOGY_REQUIREMENTS) python3,$(PYTHON))
15+
TOPOLOGY_BASE ?= origin/main
1216

13-
.PHONY: help check test test-minimal test-unit test-replay typecheck format-check format replay replay-regenerate topology topology-diff
17+
.PHONY: help check test test-minimal test-unit test-replay typecheck format-check format replay replay-regenerate topology topology-diff topology-check
1418

1519
help: ## List development commands
1620
@awk 'BEGIN { FS = ":.*## " } /^[a-z-]+:.*## / { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
17-
@printf '\nExamples:\n make test TEST=tests/unit/formatter_spec.lua\n make test TEST=unit FILTER="Timer"\n make typecheck ARGS="-f github"\n make replay ARGS="-c ReplayAll"\n make replay-regenerate FILE=v2/formatters.json\n make topology ARGS="--json"\n make topology-diff ARGS="--from main --to HEAD --json"\n'
21+
@printf '\nExamples:\n make test TEST=tests/unit/formatter_spec.lua\n make test TEST=unit FILTER="Timer"\n make typecheck ARGS="-f github"\n make replay ARGS="-c ReplayAll"\n make replay-regenerate FILE=v2/formatters.json\n make topology ARGS="--json"\n make topology-diff ARGS="--from main --to HEAD --json"\n make topology-check TOPOLOGY_BASE=upstream/main\n'
1822

1923
check: format-check typecheck test ## Run formatting, type checks, and all tests
2024

@@ -46,7 +50,10 @@ replay-regenerate: ## Regenerate snapshots with confirmation (optional FILE rela
4650
env -u VIM -u VIMRUNTIME ./tests/manual/regenerate_expected.sh $(if $(FILE),"$(FILE)")
4751

4852
topology: ## Scan dependency topology (optional scanner ARGS)
49-
$(PYTHON) $(TOPOLOGY) scan $(ARGS)
53+
$(TOPOLOGY_PYTHON) $(TOPOLOGY) scan $(ARGS)
5054

5155
topology-diff: ## Compare dependency topology snapshots (optional scanner ARGS)
52-
$(PYTHON) $(TOPOLOGY) diff $(ARGS)
56+
$(TOPOLOGY_PYTHON) $(TOPOLOGY) diff $(ARGS)
57+
58+
topology-check: ## Fail on new layer-rule breaks, import loops, or modules without a layer (vs TOPOLOGY_BASE)
59+
$(TOPOLOGY_PYTHON) $(TOPOLOGY) diff --from $(TOPOLOGY_BASE) --to worktree --check $(ARGS)

‎lua/opencode/keymap.lua‎

Lines changed: 14 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,20 @@
11
local M = {}
22
local store = require('opencode.state.store')
3+
local mapper = require('opencode.ui.keymap')
34
local window_keymaps = {}
45

5-
local function normalize_lhs(lhs)
6-
return vim.api.nvim_replace_termcodes(lhs, true, true, true)
7-
end
8-
9-
local function is_completion_visible()
10-
return require('opencode.ui.completion').is_completion_visible()
11-
end
12-
13-
---@param key_binding string The key binding to feed if completion is visible
14-
---@param callback function The callback to execute if completion is not visible
15-
local function wrap_with_completion_check(key_binding, callback)
16-
return function()
17-
if is_completion_visible() then
18-
return vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key_binding, true, false, true), 'n', false)
19-
end
20-
return callback()
21-
end
22-
end
23-
24-
---@param func_name string|function
25-
---@param func_args any
26-
---@param actions? table<string, function?> Buffer-local overrides take precedence over panel commands
27-
---@return function|nil
28-
local function resolve_callback(func_name, func_args, actions)
29-
if type(func_name) == 'string' and actions and actions[func_name] then
30-
func_name = actions[func_name]
31-
end
32-
if type(func_name) == 'function' then
33-
return function()
34-
local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {}
35-
return func_name(unpack(args))
36-
end
37-
end
38-
39-
if type(func_name) == 'string' then
40-
local commands = require('opencode.commands')
41-
local command_defs = commands.get_commands()
42-
if command_defs[func_name] then
43-
return function()
44-
local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {}
45-
local parsed = commands.build_parsed_intent(func_name, args)
46-
return commands.execute_parsed_intent(parsed)
47-
end
48-
end
49-
50-
vim.notify('Cannot find keymap action: ' .. func_name, vim.log.levels.ERROR)
51-
return nil
52-
end
53-
54-
return nil
55-
end
56-
57-
---@param keymap_config table The keymap configuration table
58-
---@param default_modes table Default modes for these keymaps
59-
---@param base_opts table Base options to use for all keymaps
60-
---@param preserve_existing? boolean
61-
---@param actions? table<string, function?>
62-
local function process_keymap_entry(keymap_config, default_modes, base_opts, preserve_existing, actions)
63-
-- Commands load diff handlers, which also use this mapper for their buffers.
6+
mapper.resolve_command = function(name, args)
647
local commands = require('opencode.commands')
65-
local command_defs = commands.get_commands()
66-
67-
for key_binding, config_entry in pairs(keymap_config) do
68-
if config_entry == false then
69-
-- Skip keymap if explicitly set to false (disabled)
70-
elseif config_entry then
71-
local func_name = config_entry[1]
72-
local func_args = config_entry[2]
73-
local callback = resolve_callback(func_name, func_args, actions)
74-
75-
local modes = config_entry.mode or default_modes
76-
if preserve_existing and base_opts.buffer then
77-
local missing_modes = {}
78-
for _, mode in ipairs(type(modes) == 'table' and modes or { modes }) do
79-
local exists = false
80-
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(base_opts.buffer, mode)) do
81-
if normalize_lhs(mapping.lhs) == normalize_lhs(key_binding) then
82-
exists = true
83-
break
84-
end
85-
end
86-
if not exists then
87-
table.insert(missing_modes, mode)
88-
end
89-
end
90-
modes = missing_modes
91-
end
92-
local opts = vim.tbl_deep_extend('force', {}, base_opts)
93-
opts.nowait = config_entry.nowait
94-
local command_desc = type(func_name) == 'string'
95-
and not (actions and actions[func_name])
96-
and vim.tbl_get(command_defs, func_name, 'desc')
97-
opts.desc = config_entry.desc or command_desc or ''
98-
99-
if not callback then
100-
if type(func_name) ~= 'string' then
101-
vim.notify(string.format('No action found for keymap: %s -> %s', key_binding, func_name), vim.log.levels.WARN)
102-
end
103-
elseif #modes > 0 then
104-
if config_entry.defer_to_completion then
105-
callback = wrap_with_completion_check(key_binding, callback)
106-
end
107-
vim.keymap.set(modes, key_binding, callback, opts)
108-
end
109-
end
8+
local definition = commands.get_commands()[name]
9+
if not definition then
10+
vim.notify('Cannot find keymap action: ' .. name, vim.log.levels.ERROR)
11+
return nil
11012
end
13+
return function()
14+
local arguments = args and (type(args) == 'table' and vim.deepcopy(args) or { args }) or {}
15+
return commands.execute_parsed_intent(commands.build_parsed_intent(name, arguments))
16+
end,
17+
definition.desc
11118
end
11219

11320
local function setup_panel_keymaps(_, windows, previous)
@@ -123,14 +30,14 @@ local function setup_panel_keymaps(_, windows, previous)
12330
and vim.api.nvim_buf_is_valid(buf)
12431
and (name == 'tab_strip' or win and vim.api.nvim_win_is_valid(win))
12532
then
126-
M.setup_window_keymaps(window_keymaps[name .. '_window'], buf, true)
33+
mapper.setup_window_keymaps(window_keymaps[name .. '_window'], buf, true)
12734
end
12835
end
12936
end
13037

131-
---@param keymap OpencodeKeymap The keymap configuration table
38+
---@param keymap OpencodeKeymap
13239
function M.setup(keymap)
133-
process_keymap_entry(keymap.editor or {}, { 'n', 'v' }, { silent = false })
40+
mapper.setup_keymaps(keymap.editor or {}, { 'n', 'v' }, { silent = false })
13441
window_keymaps = keymap
13542
store.subscribe('windows', setup_panel_keymaps)
13643
setup_panel_keymaps(nil, store.get('windows'))
@@ -141,16 +48,4 @@ function M.teardown()
14148
window_keymaps = {}
14249
end
14350

144-
---@param keymap_config table Window keymap configuration
145-
---@param buf_id integer Buffer ID to set keymaps for
146-
---@param preserve_existing? boolean
147-
---@param actions? table<string, function?> Local action overrides; unknown names use command resolution and its error notification
148-
function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing, actions)
149-
if not vim.api.nvim_buf_is_valid(buf_id) then
150-
return
151-
end
152-
153-
process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing, actions)
154-
end
155-
15651
return M

‎lua/opencode/protocols/observation.lua‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ local resource_names = {
5858
---@field on_stream_error? fun(observation: OpencodeObservation, message: string)
5959
---@field on_close? fun(observation: OpencodeObservation)
6060

61+
---@class OpencodeMessageSelection
62+
---@field mode? string
63+
---@field model? string
64+
---@field variant? string
65+
---@field default_mode? string
66+
---@field default_model? string Server config model, used when no model is selected
67+
---@field available_agents? string[] Primary agents; only these may update the tab mode
68+
6169
---@class OpencodeObservation
6270
---@field _connection table
6371
---@field _session_id string
@@ -70,7 +78,8 @@ local resource_names = {
7078
---@field _event_revisions table<OpencodeObservedResource, integer>
7179
---@field reply_permission fun(self: OpencodeObservation, request_id: string, answer: {choice: 'once'|'always'|'reject', message?: string}): Promise<boolean>
7280
---@field validate_message_options fun(self: OpencodeObservation, opts: SendMessageOpts, default_system?: string)
73-
---@field prepare_message fun(self: OpencodeObservation, opts: SendMessageOpts, selected: {mode?: string, model?: string, variant?: string, default_mode?: string, default_model?: string, available_agents?: string[]}): table, OpencodeSessionTabModelUpdate
81+
---@field prepare_message fun(self: OpencodeObservation, opts: SendMessageOpts, selected: OpencodeMessageSelection): table, OpencodeSessionTabModelUpdate
82+
---@field needs_message_defaults? boolean Caller must fill default_model/available_agents in the selection
7483
local Observation = {}
7584
Observation.__index = Observation
7685

‎lua/opencode/protocols/v1/observation.lua‎

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -16,42 +16,27 @@ local lifecycle = require('opencode.protocols.observation')
1616
local id = require('opencode.id')
1717
local Promise = require('opencode.promise')
1818
local util = require('opencode.util')
19-
local config_file = require('opencode.config_file')
2019
local event_capture = require('opencode.event_capture')
2120

2221
local M = {}
2322

2423
---@param opts SendMessageOpts
25-
---@param selected {mode?: string, model?: string, variant?: string, default_mode?: string}
24+
---@param selected OpencodeMessageSelection
2625
---@return table, OpencodeSessionTabModelUpdate
2726
function M.prepare_message(opts, selected)
2827
if opts.skills and #opts.skills > 0 then
2928
error('V1 submit does not support native skill attachments')
3029
end
31-
---@type {mode?: string, model?: string, variant?: string, default_mode?: string, default_model?: string, available_agents?: string[]}
32-
local message_selection = {
33-
mode = selected.mode,
34-
model = selected.model,
35-
variant = selected.variant,
36-
default_mode = selected.default_mode,
37-
}
38-
if opts.model == nil and selected.model == nil then
39-
local remote_config = config_file.get_opencode_config():await()
40-
message_selection.default_model = remote_config and remote_config.model ~= '' and remote_config.model or nil
41-
end
42-
if opts.agent or message_selection.mode or message_selection.default_mode then
43-
message_selection.available_agents = config_file.get_opencode_agents():await()
44-
end
4530

4631
local explicit_model = opts.model ~= nil
4732
if opts.agent == nil then
48-
opts.agent = message_selection.mode or message_selection.default_mode
33+
opts.agent = selected.mode or selected.default_mode
4934
end
5035
if opts.model == nil then
51-
opts.model = message_selection.model or message_selection.default_model
36+
opts.model = selected.model or selected.default_model
5237
end
5338
if opts.variant == nil then
54-
opts.variant = message_selection.variant
39+
opts.variant = selected.variant
5540
end
5641

5742
local overrides = {}
@@ -74,7 +59,7 @@ function M.prepare_message(opts, selected)
7459
end
7560
if opts.agent then
7661
overrides.agent = opts.agent
77-
if vim.tbl_contains(message_selection.available_agents or {}, opts.agent) then
62+
if vim.tbl_contains(selected.available_agents or {}, opts.agent) then
7863
update.mode = opts.agent
7964
end
8065
end
@@ -1049,6 +1034,7 @@ function M.new(connection, ref)
10491034
observation._v1_history_complete = false
10501035
observation._v1_history_limit = 50
10511036
observation._v1_older_loading = false
1037+
observation.needs_message_defaults = true
10521038
function observation.prepare_message(_, opts, selected)
10531039
return M.prepare_message(opts, selected)
10541040
end

‎lua/opencode/services/messaging.lua‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local state = require('opencode.state')
22
local context = require('opencode.context')
33
local util = require('opencode.util')
44
local config = require('opencode.config')
5+
local config_file = require('opencode.config_file')
56
local Promise = require('opencode.promise')
67
local log = require('opencode.log')
78
local session_runtime = require('opencode.services.session_runtime')
@@ -56,12 +57,22 @@ local function prepare_message(observation, prompt, opts)
5657
end, submission_context.review_comments or {})
5758
submission_context.automatic_context = {}
5859
local previous_context = state.last_sent_context and vim.deepcopy(state.last_sent_context)
60+
---@type OpencodeMessageSelection
5961
local selected = {
6062
mode = state.current_mode,
6163
model = state.current_model,
6264
variant = state.current_variant,
6365
default_mode = config.default_mode,
6466
}
67+
if observation.needs_message_defaults then
68+
if opts.model == nil and selected.model == nil then
69+
local remote_config = config_file.get_opencode_config():await()
70+
selected.default_model = remote_config and remote_config.model ~= '' and remote_config.model or nil
71+
end
72+
if opts.agent or selected.mode or selected.default_mode then
73+
selected.available_agents = config_file.get_opencode_agents():await()
74+
end
75+
end
6576
local overrides, model_update = observation:prepare_message(opts, selected)
6677
local params = context
6778
.format_message(prompt, opts.context, {

‎lua/opencode/ui/formatter/utils.lua‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ function M.add_tool_diff_action(output, message, context, path, first_line, last
132132
if not context or not context.interactive or not message then
133133
return
134134
end
135-
local connection = require('opencode.state').opencode_server
135+
local connection = require('opencode.state.store').get('opencode_server')
136136
if not connection or connection.protocol ~= 'v2' then
137137
return
138138
end

0 commit comments

Comments
 (0)