Skip to content

Commit 456dd01

Browse files
committed
Merge remote-tracking branch 'origin/openspec/add-vale-rule-engine-5-integration' into openspec/agent-command-and-vale-authoring
2 parents aee086d + 87f8b4d commit 456dd01

12 files changed

Lines changed: 199 additions & 52 deletions

File tree

‎.agents/skills/iterate-pr/SKILL.md‎

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,16 @@ exactly once, at the END of the work — so a PR that still carries an in-progre
246246
change directory will fail this check. Archiving on an intermediate PR is wrong:
247247
it would remove the change docs before the implementation PRs above it merge.
248248

249-
The workflow's trigger is `pull_request.branches: [main]`, but do NOT read that
250-
as "it only runs on PRs whose base is `main`." Under GitHub's stacked-PR support
251-
a stacked PR targets `main` eventually and the filter matches that eventual
252-
target, so this workflow runs on mid-stack PRs as well. Expect to see the check
253-
on every PR in a stack and decide from stack position, not from the `on:` block.
249+
This workflow carries **no `branches:` filter** — that is why it runs on every
250+
PR in a stack, and it is the reliable way to get that behavior. Expect to see
251+
the check on every PR in a stack and decide from stack position, not from the
252+
`on:` block.
253+
254+
Do not generalize from workflows that DO filter on `branches: [main]`. GitHub
255+
sometimes resolves a stacked PR's eventual target and matches on that, so such a
256+
workflow may appear on mid-stack PRs — but it stops without warning (see
257+
"two other failures that are structural" below). A filter-less trigger is the
258+
only dependable way to run everywhere.
254259

255260
The archive job is also skipped while a PR is a **draft**. A spec-only proposal
256261
is its own tip until its implementation is stacked on top, so the gate would
@@ -300,13 +305,21 @@ Mid-stack PRs bypass the check on their base ref. If you see one failing it,
300305
look at that guard rather than reaching for the label, which would wrongly
301306
record the change as shipping no release note.
302307

303-
**Do not read `on: pull_request: branches: [main]` as "this only runs on the
304-
bottom PR."** Under GitHub's stacked-PR support a stacked PR targets `main`
305-
eventually, and the filter matches that eventual target — so these workflows run
306-
on mid-stack PRs too. A workflow that must act only on the PR merging to `main`
307-
has to establish that from the base ref or its stack position. When judging
308-
whether a check "should even be running here," check the stack rather than the
309-
`on:` block.
308+
**`on: pull_request: branches: [main]` tells you nothing dependable about where
309+
a workflow runs.** GitHub sometimes resolves a stacked PR's eventual target and
310+
matches on that, so such a workflow may run on mid-stack PRs — and may also
311+
silently stop. Measured on the #71→#106 stack: every PR up to #102 got a
312+
`Validate` run, #103 and #106 got none, across 16 `pull_request` events that
313+
filter-less workflows handled fine. #103 reached "ready for review" as a
314+
~93-file change never linted, typechecked, or tested in CI.
315+
316+
So: a workflow that must run **everywhere** carries no `branches:` filter (and
317+
names `ready_for_review` in `types:`, which is not in the default set). A
318+
workflow that must act only on the PR merging to `main` establishes that from
319+
the base ref or its stack position, inside the job. Either way, when judging
320+
whether a check "should even be running here," check the stack — and if a check
321+
you expected is simply **absent**, suspect the filter before assuming the PR is
322+
fine. An absent check reads like a passing one.
310323

311324
### Never leave a PR on red
312325

‎.github/workflows/ci.yml‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,24 @@ name: CI
33
on:
44
push:
55
branches: [main]
6+
# No `branches:` filter, deliberately. Lint, typecheck, and tests have no
7+
# interest in where a PR eventually merges, and filtering on `main` silently
8+
# skipped this workflow on stacked PRs.
9+
#
10+
# The filter matches the PR's base ref, but GitHub also resolves a stacked
11+
# PR's *eventual* target and matches on that — so `branches: [main]` did run
12+
# on PRs based on another branch, until it stopped. Measured on the
13+
# #71→#93→#94→#95→#100→#102→#103→#106 stack: every PR up to #102 got a
14+
# `Validate` run, while #103 and #106 got none, across 16 `pull_request`
15+
# events that other workflows handled fine. A filter that works for six PRs
16+
# and quietly fails on the seventh is worse than one that never worked,
17+
# because nobody re-checks it.
18+
#
19+
# `ready_for_review` is NOT in the default set (opened/synchronize/reopened)
20+
# and must be named: without it a draft marked ready gets no fresh run until
21+
# something happens to push again, which is exactly the state #103 sat in.
622
pull_request:
7-
branches: [main]
23+
types: [opened, synchronize, reopened, ready_for_review]
824

925
permissions:
1026
contents: read

‎.github/workflows/require-changeset.yml‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,15 @@ jobs:
5454
# `Require a changeset` check run (e.g. run 30786954198, event
5555
# `pull_request`, head `openspec/partition-engine-2-dispatch`).
5656
#
57-
# So `branches:` no longer scopes a workflow to the bottom of a stack.
57+
# That resolution is undocumented and it also stops without warning:
58+
# on the #71→#106 stack this workflow ran up to #102 and then produced
59+
# no check run at all on #103 or #106. So `branches:` is unreliable in
60+
# BOTH directions — it neither scopes a workflow to the bottom of a
61+
# stack nor guarantees it reaches every PR. This guard stays because
62+
# the first failure mode is the dangerous one here; a workflow that
63+
# must reach every PR should instead carry no `branches:` filter, as
64+
# `ci.yml` and `pr-check-openspec.yml` do.
65+
#
5866
# Any job whose correctness depends on "is this the PR that merges to
5967
# main" has to establish that itself, as this one does.
6068
if [ "$BASE_REF" != "main" ]; then

‎CLAUDE.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,16 @@ Note how this interacts with the archive gate (see the OpenSpec archive check be
110110
- **The changeset belongs on the bottom PR**, the one that targets `main`. That is the only place it can live: a changeset added on the tip is invisible to the bottom PR's diff, so the check would fail on the PR that actually merges.
111111
- **Mid-stack PRs bypass the check**, because the base branch is not `main`. They inherit the base's changeset rather than adding one, so there is nothing for the check to find. **Do not label them `skip-changeset`** — the label records a deliberate "this change ships no release note," which is false here, and the bypass already handles it.
112112

113-
The bypass is an in-step check on the base ref, and it is load-bearing. **`branches: [main]` no longer means "only the PR whose base is `main`."** Under GitHub's stacked-PR support, a PR in a stack is understood to target `main` eventually, so the filter matches on that eventual target and the workflow runs on mid-stack PRs as well — observed here on #73, #80, and #81, all with `openspec/partition-engine-*` bases.
113+
The bypass is an in-step check on the base ref, and it is load-bearing. **`branches: [main]` does not reliably mean either "only the PR whose base is `main`" or "every PR in the stack."** The filter matches the PR's base ref, but GitHub also resolves a stacked PR's _eventual_ target and sometimes matches on that instead, so the workflow runs on mid-stack PRs — observed on #73, #80, and #81, all with `openspec/partition-engine-*` bases.
114114

115-
The general rule that follows: **any workflow whose correctness depends on "is this the PR that merges to `main`" must determine that itself** — from the base ref, or by resolving stack position — and cannot lean on the `on:` filter to scope it. If you see a mid-stack PR failing this check, look at that guard rather than reaching for the label.
115+
**Do not depend on that resolution. It is undocumented and it stops without warning.** On the #71→#93→#94→#95→#100→#102→#103→#106 stack, every PR up to #102 got a `Validate` run and **#103 and #106 got none** — across 16 `pull_request` events that filter-less workflows handled fine. #103 was a ~93-file change that reached "ready for review" having never been linted, typechecked, or tested in CI. Depth correlates (#102 is six hops from `main`, #103 seven) but nothing confirms a cap, and it was not a date cutoff: #102 kept getting runs after #103 had already stopped. A filter that works for six PRs and quietly fails on the seventh is worse than one that never worked, because nobody re-checks it.
116+
117+
Two rules follow, and they pull in opposite directions:
118+
119+
- **A workflow that must run everywhere carries no `branches:` filter at all.** Lint, typecheck, and tests have no interest in where a PR eventually merges. `ci.yml` dropped its filter for exactly this reason; `pr-check-openspec.yml` and `stack-breadcrumb.yml` never had one, which is why they kept running on #103. If you add such a workflow, also name `ready_for_review` in `types:` — it is not in the default set (`opened`/`synchronize`/`reopened`), so without it a draft marked ready gets no fresh run until someone happens to push again.
120+
- **A workflow whose correctness depends on "is this the PR that merges to `main`" must determine that itself** — from the base ref, or by resolving stack position — and cannot lean on the `on:` filter to scope it. If you see a mid-stack PR failing the changeset check, look at that guard rather than reaching for the label.
121+
122+
The shared point: the `on:` filter is not a reliable answer to "where does this PR land." Let the workflow run, and decide inside it.
116123

117124
Put the changeset at the base and every branch above inherits it, since a child contains its ancestors' commits.
118125

‎openspec/changes/archive/2026-08-11-add-vale-rule-engine/specs/cli-check/spec.md‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,3 @@
1313

1414
- **WHEN** the `vale` binary is unavailable but `.taskless/sg/` has rules
1515
- **THEN** the CLI reports the Vale engine as unavailable and still returns ast-grep results
16-
17-
## MODIFIED Requirements

‎packages/cli/src/commands/check.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,10 @@ export const checkCommand = defineCommand({
282282
const telemetry = await getTelemetry(cwd);
283283

284284
// Warnings/notices are advisory human output; suppress them under --json so
285-
// the machine output stays the { success, results, skipped? } shape.
285+
// the machine output stays the
286+
// { success, results, skipped?, failures?, notices? } shape. Engine
287+
// failures and notices are carried in that envelope instead, since a
288+
// machine consumer cannot read stderr prose.
286289
const warn = (message: string) => {
287290
if (!args.json) console.error(message);
288291
};
@@ -422,6 +425,12 @@ export const checkCommand = defineCommand({
422425
success: exitCode === 0,
423426
results,
424427
...(plan.skipped.length > 0 ? { skipped: plan.skipped } : {}),
428+
...(dispatched.failures.length > 0
429+
? { failures: dispatched.failures }
430+
: {}),
431+
...(dispatched.notices.length > 0
432+
? { notices: dispatched.notices }
433+
: {}),
425434
});
426435
console.log(JSON.stringify(output));
427436
} else {

‎packages/cli/src/rules/dispatch.ts‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ import { join } from "node:path";
33

44
import type { CheckResult } from "../types/check";
55
import { dedupeFindings, ENGINE_LAYOUTS, type EngineName } from "./engines";
6+
import { isMissingDirectory } from "./errno";
67
import { executeRuntimeRules } from "./runtime/harness";
78
import type { RuntimeRule } from "./runtime/discover";
89
import { runAstGrepScan } from "./scan";
910
import { runVale } from "./vale/run";
1011

11-
/** Errno values that mean "the directory is not there", and nothing worse. */
12-
const ABSENT_DIRECTORY_CODES = new Set(["ENOENT", "ENOTDIR"]);
13-
1412
/**
1513
* Whether `.taskless/vale/rules/` holds anything to run.
1614
*
@@ -34,8 +32,7 @@ export async function hasValeRules(cwd: string): Promise<boolean> {
3432
);
3533
return entries.some((entry) => entry.endsWith(".yml"));
3634
} catch (error) {
37-
const code = (error as NodeJS.ErrnoException).code;
38-
if (code !== undefined && ABSENT_DIRECTORY_CODES.has(code)) return false;
35+
if (isMissingDirectory(error)) return false;
3936
throw error;
4037
}
4138
}
@@ -115,17 +112,22 @@ export interface DispatchResult {
115112
* `sg/rules/` and the legacy `.taskless/rules/` are scanned separately, so a
116113
* rule present in both reports twice; the finding is its own identity, so
117114
* identical matches collapse.
115+
*
116+
* The configs are scanned concurrently, for the same reason the engines are:
117+
* each is an independent subprocess over the same paths, and a project holding
118+
* both `sg/rules/` and the legacy `rules/` should not pay their latencies in
119+
* series. `Promise.all` preserves input order in its output, so the flattened
120+
* results are ordered by config exactly as the sequential loop left them.
118121
*/
119122
async function runAstGrepEngine(
120123
options: DispatchOptions
121124
): Promise<EngineOutcome> {
122-
const results: CheckResult[] = [];
123-
for (const configPath of options.astGrepConfigPaths) {
124-
const scan = await runAstGrepScan(options.cwd, options.paths, {
125-
configPath,
126-
});
127-
results.push(...scan.results);
128-
}
125+
const scans = await Promise.all(
126+
options.astGrepConfigPaths.map((configPath) =>
127+
runAstGrepScan(options.cwd, options.paths, { configPath })
128+
)
129+
);
130+
const results: CheckResult[] = scans.flatMap((scan) => scan.results);
129131
return { engine: "sg", results: dedupeFindings(results) };
130132
}
131133

‎packages/cli/src/rules/errno.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Whether an `fs` failure genuinely means "that directory is not there".
3+
*
4+
* `ENOENT` is the path not existing; `ENOTDIR` is a path that exists but is a
5+
* file, or that has a file for an ancestor. Every other code — `EACCES` above
6+
* all — is a real IO problem, and reading it as "nothing here" is what makes an
7+
* unreadable bucket indistinguishable from an unwritten one.
8+
*
9+
* One definition, so no caller can accidentally answer that question
10+
* differently: two spellings of "absent" drift, and the drift is silent.
11+
*/
12+
export function isMissingDirectory(error: unknown): boolean {
13+
if (error === null || typeof error !== "object" || !("code" in error)) {
14+
return false;
15+
}
16+
const { code } = error as NodeJS.ErrnoException;
17+
return code === "ENOENT" || code === "ENOTDIR";
18+
}

‎packages/cli/src/rules/vale/binary.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
pathCommandName,
23
resolvePlatformBinary,
34
type PlatformBinaryResolution,
45
type PlatformBinarySpec,
@@ -54,11 +55,17 @@ export function resetValeBinaryCache(): void {
5455
cached = undefined;
5556
}
5657

57-
/** An actionable message naming where we looked. */
58+
/**
59+
* An actionable message naming where we looked.
60+
*
61+
* The PATH advice is spelled for the platform — `vale.exe` on Windows — rather
62+
* than hardcoded, so a Windows user is not told to install a name that the
63+
* resolver would not find there.
64+
*/
5865
export function valeUnavailableMessage(tried: string[]): string {
5966
return (
6067
`Vale binary not found. Looked in: ${tried.join(", ")}. Install a ` +
61-
`supported platform build, or put \`vale\` on your PATH. Other engines ` +
62-
`still ran.`
68+
`supported platform build, or put \`${pathCommandName(VALE_BINARY)}\` on ` +
69+
`your PATH. Other engines still ran.`
6370
);
6471
}

‎packages/cli/src/rules/vale/verify.ts‎

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
44
import { join, posix, relative, resolve, sep } from "node:path";
55

66
import { ENGINE_LAYOUTS } from "../engines";
7+
import { isMissingDirectory } from "../errno";
78
import { runVale, type ValeRunOutcome } from "./run";
89

910
/** Where a rule's fixtures live, relative to the project root. */
@@ -49,22 +50,6 @@ export function buildIsolatingConfig(cwd: string, ruleId: string): string {
4950
].join("\n");
5051
}
5152

52-
/**
53-
* Whether a `readdir` failure genuinely means "that directory is not there".
54-
*
55-
* `ENOENT` is the path not existing; `ENOTDIR` is a path that exists but is a
56-
* file, or that has a file for an ancestor. Every other code — `EACCES` above
57-
* all — is a real IO problem, and reading it as "nothing here" is what makes an
58-
* unreadable bucket indistinguishable from an unwritten one.
59-
*/
60-
function isMissingDirectory(error: unknown): boolean {
61-
if (error === null || typeof error !== "object" || !("code" in error)) {
62-
return false;
63-
}
64-
const { code } = error as NodeJS.ErrnoException;
65-
return code === "ENOENT" || code === "ENOTDIR";
66-
}
67-
6853
/**
6954
* Directory entries, with a directory that is not there reading as an empty one.
7055
*
@@ -254,9 +239,10 @@ export async function verifyValeRule(
254239

255240
const configDirectory = mkdtempSync(join(tmpdir(), `vale-verify-${ruleId}-`));
256241
const configPath = join(configDirectory, ".vale.ini");
257-
writeFileSync(configPath, buildIsolatingConfig(cwd, ruleId));
258242

259243
try {
244+
writeFileSync(configPath, buildIsolatingConfig(cwd, ruleId));
245+
260246
const outcome = await runVale({
261247
cwd,
262248
configPath,

0 commit comments

Comments
 (0)