Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f5e9687
docs(openspec): plan the negative search with repeated -e
dan-petty Nov 5, 2026
4b292ca
test(roadmap): cover repeated -e literals in the negative search
dan-petty Nov 5, 2026
04292a5
test(roadmap): read the literals of a negative search as a tuple
dan-petty Nov 5, 2026
224ad46
test(roadmap): check each -e literal of a negative search in the stag…
dan-petty Nov 5, 2026
3d18a66
test(roadmap): the stage prompt names the -e form of a negative search
dan-petty Nov 5, 2026
e6dc53f
feat(roadmap): accept repeated -e literals in a negative search
dan-petty Nov 5, 2026
83910ca
docs(roadmap): the stage prompt states the -e form of a negative search
dan-petty Nov 5, 2026
2992ca3
docs(openspec): name the #2610 image pins with -e literals
dan-petty Nov 5, 2026
d10c195
docs(testing): state the form of the negative search
dan-petty Nov 5, 2026
f9c43df
docs(openspec): name the follow-up issues of the negative search change
dan-petty Nov 5, 2026
a9bb4da
docs(openspec): archive the negative search change
dan-petty Nov 5, 2026
531726d
Revert "docs(openspec): archive the negative search change"
dan-petty Nov 5, 2026
e5c4512
feat(roadmap): shorten the reason for an -e form that is refused
dan-petty Nov 5, 2026
211b45d
docs(testing): state the negative-search form in short sentences
dan-petty Nov 5, 2026
6aad468
docs(openspec): name the function _literals in task 2.2
dan-petty Nov 5, 2026
9eaebcf
fix(roadmap): name the rule when an -e literal starts with a dash
dan-petty Nov 5, 2026
c5b13de
docs(openspec): archive the negative search change
dan-petty Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ Every new test follows these rules. Some of them name a path, fixture or check t
### Which change gets a test

- A change that adds or changes what the code does gets a test. Write it first, and see it fail on the base. A fix pull request cites its `devops ci fail-to-pass --base <ref>` run.
- Every other change gets no test: a document, a manifest, a workflow, a deletion or a refactor that keeps behavior, an edit of tests only, or data. It names one command that can run again and gives the same answer, such as `uv run devops ci docs`, `uv run devops openspec check`, `! git grep -n <name> src/ tests/` (a command that exits 0 when it finds nothing), or `uv run devops k8s image-pins`. When no command exists, it gives the steps of a repeatable manual check. The `! git grep` form has a fixed shape, because the step runs it as a list of words and never as a shell: the options `-n -I -F -i -w -l -c -q`, one plain literal and relative paths. A pipe, `;`, `&&`, a redirect and any other option are refused.
- Every other change gets no test: a document, a manifest, a workflow, a deletion or a refactor that keeps behavior, an edit of tests only, or data. It names one command that can run again and gives the same answer, such as `uv run devops ci docs`, `uv run devops openspec check`, `! git grep -n <name> src/ tests/` (a command that exits 0 when it finds nothing), or `uv run devops k8s image-pins`. When no command exists, it gives the steps of a repeatable manual check. The `! git grep` form has a fixed shape. The step runs it as a list of words, never as a shell. It takes the options `-n -I -F -i -w -l -c -q`, then one plain literal and relative paths. Or it takes one or more `-e` options, each with a plain literal, then `--` before the relative paths. A literal after `-e` must not start with `-`. A pipe, `;`, `&&`, a redirect, `-E`, `-P` and any other option are refused.
- A task group of a plan has the same two kinds, `behavior` and `other` (`GroupKind`). The stage job refuses a `test` on an `other` group, and refuses a `behavior` group that changes no path under `src/`. A test compares the prompts of the job with these kinds.

### Doubles
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Reads: `src/devops_cli/k8s/image_pins.py`, `src/devops_cli/commands/k8s/image_pi
- [ ] 3.2 In `src/devops_cli/k8s/image_pins.py`, give `resolve_image` and `resolve_image_pins` a client and a timeout. `resolve_image` calls `tag_digest` and records `RegistryReadError`, `SSRFBlockedError` and `httpx2.HTTPError` in `PinResolution.error`. Delete `inspect_command`, the process call, the `planned` field of `PinResolution`, and the imports and constants that this leaves unused. In `src/devops_cli/commands/k8s/image_pins.py`, open `new_http_client(level=EgressLevel.PUBLIC)` with `DEFAULT_HTTP_TIMEOUT_SECONDS` only outside a dry run, remove the "planned" text from `_result_text`, and change the dry-run message in `src/devops_cli/lang/en/messages.py` to name the HEAD request. Write the docstring of the module and the help text of the command as they read now: a HEAD request that Docker Hub does not count
- Verification: `uv run devops ci test tests/k8s/test_image_pins.py tests/docker/test_registry_tag_digest.py tests/argo/test_image_pins.py` passes
- [ ] 3.3 Delete the four files of the imagetools fake, its recording, its record and its contract suite
- Verification: `! git grep -n -E "ImagetoolsFake|inspect_command|imagetools_recording|imagetools_runs" -- src tests` prints nothing, and `uv run devops ci test tests/k8s tests/docker tests/argo` passes
- Verification: `! git grep -n -e "ImagetoolsFake" -e "inspect_command" -e "imagetools_recording" -e "imagetools_runs" -- src tests` prints nothing, and `uv run devops ci test tests/k8s tests/docker tests/argo` passes

## 4. Fix the documents that name the old resolver

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-05
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Design

## Context

The negative search is `! git grep [options] <literal> [--] <path>...` (#3263). The step reads the words in `read_negative_search` (`src/devops_cli/roadmap/implement_shell_policy.py`). The stage job reads the same function through `negative_search_parts` (`src/devops_cli/roadmap/stage_form.py`) and `_literal` (`src/devops_cli/roadmap/stage_move.py`). One literal is the only form the reader accepts.

## Decisions

### Repeat `-e`, and keep the positional literal

Git matches a line that holds any of several `-e` patterns, with no `--and` and no `--all-match` (checked on git 2.55.0). A group with four names then needs one line, not four tasks.

- Rejected: one `! git grep` line for each name. Each name adds a line or a task, and the plan loses its one-line check of a group. The two-name search of the first acceptance criterion cannot run as one line.
- Rejected: replace the positional literal with `-e` only. Committed plans, such as task 1.1 of #2610, use the positional form. Dropping it refuses every committed line of that shape.

### Each `-e` value is a plain literal

Each value passes the same check as the positional literal: the signs of `NEGATIVE_SEARCH_LITERAL_SIGNS` and no leading `-`. A value that starts with `-` is refused with its own reason, so `-E`, `-P` and an option word stay refused as a value. The value `--` gets its own reason: it is a missing literal.

- Rejected: let a value start with `-`. Git would read it as a pattern, but the grammar could no longer tell an option word from a literal, and `-E` as a value would pass unnoticed.

### `--` is required with `-e`, and no word may come before it

With `-e`, git reads a positional word as a path. The search would then cover other paths, and the plan would not say so. The reader requires `--` after the last `-e` value and refuses any word between them.

- Rejected: let git read the word as a path. The search covers paths that the plan does not name.
- Rejected: accept a mixed form with a positional literal and `-e`. A plain word is both a literal and a path, so the reader cannot tell which the plan meant.

### The stage job checks each literal

`record_search_evidence` records the staged hits for each literal. `search_problems` refuses each literal with no staged hit, and each literal that a created module keeps. A literal with no staged hit already passes before the change, so the check holds for each literal alone.

- Rejected: check only the first literal. A second literal with no hit proves nothing, and the stage job would accept it.

### Move groups check each literal

`move_verify_problems` refuses a move group when any literal names one module of the moved prefix.

## Rejected for the scope of this change

- A test over every committed `tasks.md` (AC3 of #3276). Three active plans hold refused commands that this change does not fix. #3187 holds regex lines with boundaries that the grammar cannot express, and its plan needs a rewrite by its owner. The test waits for that rewrite (see Workflow follow-up in the proposal).
- A fix of the prose code spans of `step_files.py`. It is a separate cause, so it gets its own issue.
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Proposal

## Why

The negative search of the implement step takes one literal and no `-e` option (#3263). A group that removes several names cannot check them in one command. The plan of #2610 has a group 3 line with `-E` and four names (task 3.3). The step refuses that line with "the option `-E` is not allowed", so the group cannot run (#3276).

## What Changes

- `read_negative_search` in `src/devops_cli/roadmap/implement_shell_policy.py` accepts one or more `-e <literal>` options. Each literal is a plain literal, as the positional literal is. The search takes `--` before the paths when it has `-e` options. The positional literal form stays.
- `NegativeSearch` holds a tuple of literals and a flag that marks the `-e` form. The `argv` of the search passes each literal after its own `-e`. The field of one literal goes away.
- The stage job checks each literal of a negative search. `record_search_evidence` and `search_problems` in `src/devops_cli/roadmap/stage_form.py` and `move_verify_problems` in `src/devops_cli/roadmap/stage_move.py` read every literal.
- `src/devops_cli/lang/en/messages.py` holds three new reasons: an `-e` option with no literal, a word that comes after an `-e` literal, and an `-e` value that starts with `-`.
- `src/devops_cli/ai/tasks/roadmap_stage_change.md` names the `-e` form, and `tests/roadmap/test_roadmap_implement_negative_search.py` asserts it.
- `docs/TESTING.md` states the shape of the `! git grep` form: one plain literal, or one or more `-e` literals and `--`, with the refused tokens.
- Task 3.3 of `openspec/changes/2610-devops-k8s-image-pins-resolves-with/tasks.md` uses four `-e` literals in place of `-E` and the pipe.
- The change fits one pull request. The invariant test of every committed plan is not part of it (see Workflow follow-up).

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `roadmap-implement-step`: A negative search takes one literal or repeated `-e` literals. Each literal keeps the plain-literal rule.
- `roadmap-stage`: The stage job records the hits of each literal of a negative search and checks each one.

## Impact

- Code: `src/devops_cli/roadmap/implement_shell_policy.py`, `src/devops_cli/roadmap/stage_form.py`, `src/devops_cli/roadmap/stage_move.py`, `src/devops_cli/lang/en/messages.py`
- Documents: `src/devops_cli/ai/tasks/roadmap_stage_change.md`, `docs/TESTING.md`, `openspec/changes/2610-devops-k8s-image-pins-resolves-with/tasks.md`
- Tests: `tests/roadmap/test_roadmap_implement_negative_search.py`, `tests/roadmap/test_roadmap_implement_shell_policy.py`, `tests/roadmap/test_roadmap_stage_form.py`, `tests/roadmap/test_roadmap_stage_plan.py`

## Feasibility

- `git grep` accepts `-e` more than once. A scratch repository at git 2.55.0 gave exit 0 with the matching line for `git grep -n -e alpha -e zzz -- src`, and exit 1 for `git grep -n -e zzz -e qqq -- src`. Exit 1 also holds when the path matches no file (`git grep -n -e zzz -- nothere`).
- The premise holds at origin/main (d254410c). `NEGATIVE_SEARCH_OPTION_LETTERS = "nIFiwlcq"` in `src/devops_cli/roadmap/implement_shell_policy.py` excludes `e`. The test `test_a_negative_search_outside_the_fixed_shape_is_refused_and_starts_no_process` pins `! git grep -e name src/` as refused.
- `read_verification` from `src/devops_cli/roadmap/implement_shell_policy.py` gives the refusal "the option `-E` is not allowed" for the task 3.3 line of #2610. The same reader run over every active `tasks.md` gave 16 refused commands in three plans: #2610 (group 2 prose spans and the `-E` line), #2441 (`helm` and a prose span) and #3187 (`test -z` lines and three `-E` regex lines with boundaries). Only the `-E` lines of #2610 and #3187 are negative searches, and the change fixes only #2610, because #3187 needs a regex that the grammar cannot express.
- Group 2 of #2610 also stops the step before group 3. Its verification line holds prose code spans (`tagged_image`, `nginx:1.27`) that the step reads as commands. That cause is outside this change.

## Existing Tool Considered

`git grep` covers the search. It matches any of several `-e` patterns in one command, so the change keeps git as the program that searches. Git has no option policy: it accepts `-E`, `-P` and a regex pattern. The step keeps its allowlist and its plain-literal check, and it requires `--` before the paths when `-e` is used. The change builds no search engine. The grammar in the step adds about 40 lines.

Internal code that the change deletes: the one-literal field of `NegativeSearch` (`literal`), the comment that refuses `-e` (`implement_shell_policy.py`, lines 150-151), the refusal case `! git grep -e name src/` in `tests/roadmap/test_roadmap_implement_negative_search.py`.

## Dependencies

- #3263 added the negative search. The change extends it.
- The plan of #2610 is edited in this change. #3490 also lists #2610, so its edit of that plan must follow this change or merge into it.

## Workflow follow-up

- #3579 tracks the test over every active plan (acceptance criterion 3 of #3276). This change does not add that test. After this change, 15 refused Verification commands remain in three active plans, and #3579 holds the list.
- #3596 tracks the step that reads every code span of a Verification line as a command (`src/devops_cli/roadmap/step_files.py`).
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## MODIFIED Requirements

### Requirement: A negative search is a verification command in a fixed shape
The step SHALL run a verification command that starts with `!` only as `! git grep [options] <literal> [--] <path>...` or as `! git grep [options] -e <literal> [-e <literal>]... -- <path>...`, with at least one path and with the options `-n -I -F -i -w -l -c -q` in words of those letters. The step SHALL start no process for a command outside the shape, and the reason of the stop SHALL say which rule of the shape the command breaks.

#### Scenario: A command that is not the fixed shape
- **WHEN** a Verification line names `! rm -rf src/`, `! git grep -n name src/ | cat`, `! git grep -n name src/ ; ls`, `! git grep -n name src/ && ls`, `! git grep -n name src/ > out.txt`, `! git grep -n '$(id)' src/`, `! git grep -O name src/`, `! git grep --no-index -n name src/`, `! git grep -f patterns.txt src/`, `! git grep --open-files-in-pager=cat name src/`, `! git grep -n name /etc` or `! git grep -n name ../other`
- **THEN** the step stops with the reason that says which rule of the shape the command breaks, and starts no process

#### Scenario: A letter outside ASCII
- **WHEN** a Verification line names a literal with a fullwidth `f` or an accented `é`, or a path with one
- **THEN** the step stops with the reason that the text is not a literal, or that the word is not a path, and starts no process

## ADDED Requirements

### Requirement: A negative search takes repeated -e literals
The step SHALL read each `-e` option of a negative search as the option that takes the literal after it. Each such literal SHALL hold only the characters of a positional literal and SHALL NOT start with `-`. The step SHALL run `git grep` with each literal after its own `-e`, and SHALL pass the search when git exits 1.

#### Scenario: Two names that do not occur
- **WHEN** a Verification line names `! git grep -n -e "ImagetoolsFake" -e "inspect_command" -- src tests` and no file under `src` or `tests` holds either name
- **THEN** the step runs `git grep -n -e ImagetoolsFake -e inspect_command -- src tests` and the verification command passes

#### Scenario: One of the names occurs
- **WHEN** a Verification line names `! git grep -n -e hello -e absent_name -- src/ tests/` and `src/sample.py` holds `hello`
- **THEN** the step stops with the matching lines that git printed, and starts no other process

#### Scenario: A value that is not a plain literal
- **WHEN** a Verification line names `! git grep -n -e 'a|b' -- src/`, `! git grep -n -e 'a;b' -- src/` or `! git grep -n -e -- src/`
- **THEN** the step stops with the reason that names the text, or that the `-e` option has no literal, and starts no process

#### Scenario: A value that starts with -
- **WHEN** a Verification line names `! git grep -n -e -E -- src/` or `! git grep -n -e --addons -- src/`
- **THEN** the step stops with the reason that the text starts with `-`, and starts no process

### Requirement: A negative search with -e puts -- before its paths
A negative search with `-e` options SHALL have `--` after its last literal and before its paths. It SHALL NOT have a word between the last literal and `--`.

#### Scenario: Both forms in one line
- **WHEN** a Verification line names `! git grep -e name literal -- src/`, or `! git grep -e name src/`
- **THEN** the step stops with the reason that names both forms of the search, and starts no process
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
## ADDED Requirements

### Requirement: The stage job checks each literal of a negative search
The stage job SHALL record the staged hits of each `-e` literal of a negative-search verify command, and check_proposal SHALL apply the rules of the negative search to each literal, the rule of the staged hit and the rule of the created module alike.

#### Scenario: One literal has no staged hit
- **WHEN** A group gives the verify command `! git grep -n -e old_name -e gone_name -- src/ tests/`, and the staged tree holds `old_name` but no `gone_name`.
- **THEN** check_proposal returns one problem that names the literal `gone_name` and says that the search already passes before the change.

#### Scenario: A move group names a module with a second literal
- **WHEN** A move group for prefix `stage` gives the verify command `! git grep -n -e devops_cli.roadmap.stage_ready -e devops_cli.roadmap.stage -- src/ tests/`.
- **THEN** check_proposal returns two problems: the problem of the move and the problem of the created module, and each names the literal `devops_cli.roadmap.stage`.
Loading
Loading