Skip to content

feat: one directory per rule, and path-addressed verify/test - #103

Draft
thecodedrift wants to merge 9 commits into
openspec/agent-command-and-vale-authoringfrom
openspec/self-contained-vale-rules
Draft

feat: one directory per rule, and path-addressed verify/test#103
thecodedrift wants to merge 9 commits into
openspec/agent-command-and-vale-authoringfrom
openspec/self-contained-vale-rules

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Aug 14, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Why

A rule is spread across locations today, and for Vale one of them is shared by every rule in the project. Five sandboxed harness runs against create-vale-rule (on #102) found silent failures in that shared .vale.ini and nowhere else — an assignment above the first matcher, a glob that missed the fixture's extension, three names that had to agree with nothing reporting when they didn't.

The same reasoning generalizes past Vale: sg and runtime rules are also split between a rules/ tree and a parallel rule-tests/ tree, so no engine has a single path meaning "this rule". Fixing Vale alone would leave three layouts instead of one.

What changes

One directory per rule, identical across engines:

.taskless/rules/sg/no-eval/
    no-eval.yml
    .tests/no-eval-20260101-test.yml

.taskless/rules/vale/no-simply/
    no-simply.yml
    .vale.ini            # this rule's matchers, excludes, tskl) metadata
    .tests/pass/ok.md
    .tests/fail/bad.md

.taskless/rules/runtime/unused-exports/
    check.ts
    captures/exported-symbol.yml
    .tests/…

check assembles the Vale and ast-grep configs from the per-rule tree and gitignores them. rule verify <id> is replaced by path-addressed verify <path> (required components) and test <path> (tests), with verify running as a prerequisite layer of test.

Adds example/ — a small real project so a reader can see an install rather than infer it from tests that build their own fixtures, with a check test so it can't drift.

The dot on .tests/ is load-bearing, and measured

Probe ast-grep 0.41.0 Vale 3.17.1
tests/ inside the rule dir failsmissing field 'language' fine
__tests__/ fails fine
.tests/ skipped by rule discovery fine, even containing a .yml
test runner reads it testDir: …/.tests works; snapshots land inside buckets lint when targeted

ruleDirs recurses and parses every .yml beneath as a rule, so a plain tests/ directory hard-fails the scan.

This is a dependency on undocumented behavior and D2 records it as one, with two mitigations: the failure is loud — a parse error naming the file, not a test silently reinterpreted as a rule — and a test pins it. The rejected alternative (materialize a rules-only tree for ast-grep, keeping a plain tests/) is written down as the fallback if the assumption breaks.

Other measured constraints

  • <id>/<id>.yml resolves as check <id>.<id> only under a StylesPath naming its parent; under StylesPath = . it resolves to nothing. This reverses the note in migration 0004 — correct for the flat layout, backwards for this one — so the task list rewrites that docstring rather than deleting it.
  • Vale rejects unknown keys in a style (E201), so scope cannot ride inside the style file. ast-grep can express scoping in the rule, which is why it gets no per-rule config — an empty file per rule is symmetry as decoration.

Delivery shape

Stacked, merging down on #102. 0005 layers on 0004; both are unreleased and both ship in this stack, so consumers run them as one upgrade and never observe the intermediate layout.

openspec validate --all --strict will flag the cli-agent-authoring delta until #102 archives — that capability is introduced there.

Spec-only. Implementation follows on approval.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3

A Vale rule is currently three locations, one of which every rule in the
project shares. That shared .vale.ini is where all five harness runs
found silent failures — an assignment above the first matcher, a glob
that missed the fixture extension, three names that had to agree with
nothing reporting when they didn't. A single write-contended config is
the wrong shape at any scale.

Each rule becomes a directory holding its style and its own .vale.ini;
check assembles the run config from them and gitignores it. Measured
against Vale 3.17.1: rules/<id>/<id>.yml resolves as check <id>.<id>
under StylesPath = rules and resolves to nothing under StylesPath = .,
Vale rejects unknown keys in a style so scope cannot ride along inside
it, and a .yml sidecar in a style directory is loaded as a rule and
fails E201 while a .vale.ini in the same place is ignored.

Also replaces rule verify <id> with path-addressed verify and test. An
id does not name one thing — the same id can exist under two engines,
which is why the id form needed an ambiguity error at all.

Adds an example/ project so a reader can see an install rather than
infer it from tests that build their own fixtures.

Stacked on #102 and merging down: a layout change without its migration
ships a project whose rules silently stop running.
Widens the change from Vale to every engine, and renames it to match.

One directory per rule, identical shape everywhere:
`.taskless/rules/<engine>/<id>/` holding the rule, any config that
engine requires, and its tests in `.tests/`. A rule becomes one path
rather than two, which is what makes `verify <path>` and `test <path>`
work without an id lookup.

The dot on `.tests/` is load-bearing and measured. ast-grep's ruleDirs
recurses and parses every .yml beneath as a rule, so a plain `tests/`
directory fails the scan with "missing field 'language'"; `__tests__/`
fails the same way; a dot-directory is skipped, and `sg test` still
reads it when testDir names it. Vale is unaffected either way — a
`.tests/` inside a style directory is harmless even containing a .yml.

That is a dependency on undocumented behavior, recorded as one in D2
with two mitigations: the failure is loud (a parse error naming the
file, not a silently reinterpreted test), and a test pins it. The
rejected alternative — materializing a rules-only tree for ast-grep —
is written down as the fallback if the assumption ever breaks.

Also drops the per-rule sg config: ast-grep expresses scoping inside
the rule, so the slot would be an empty file every author creates, no
author fills, and every reader learns to ignore. And renames runtime's
capture rules to `captures/`, since "matcher" now means a Vale glob
section in this same tree.

0005 layers on 0004; both are unreleased and both ship in this stack,
so consumers run them as one upgrade and never see the intermediate
layout.
@thecodedrift thecodedrift changed the title feat: self-contained Vale rules, and path-addressed verify/test feat: one directory per rule, and path-addressed verify/test Aug 14, 2026
…ting

Started the layout refactor and hit something the proposal missed:
`.taskless/rules/` is the new root and is also LEGACY_RULES_DIRECTORY,
the pre-0004 flat location. Same string, different meaning.

The legacy read paths turn out to be removable rather than renameable,
because they are unreachable: ensureTasklessDirectory runs migrations
before anything reads a rule, so 0004 has already moved
.taskless/rules/*.yml to sg/rules/ and 0005 moves it again. Under the
new layout a legacy lookup would resolve .taskless/rules/<id>.yml
inside a tree whose real contents are rules/<engine>/<id>/ — reading
the new root as though it were the old flat directory. A stale read
path that resolves into the live tree is worse than no fallback.

Also adds the migration precondition that follows: 0005 asserts the new
root holds no top-level *.yml before writing engine directories into
it, since a file still there means 0004 did not complete.

Reverting the partial engines.ts refactor so this PR stays spec-only
and green rather than carrying a tree with twelve broken callers.
Groups 1-3 of self-contained-rules, plus the `rule verify` removal.

ENGINE_LAYOUTS now describes a rule *directory* rather than parallel
rules/ and rule-tests/ trees, and every path derives from it. Tests live
in `.tests/` — the dot is load-bearing, since ast-grep's ruleDirs
recurses and parses every .yml beneath as a rule, and the constant
carries that measurement plus the fallback if it ever breaks.

Both engine configs are now assembled per run and gitignored.
Assembly is deterministic — rules sorted by id, each rule's matcher
order verbatim — because Vale's precedence is positional and a config
built in directory-iteration order would give a rule a different
effective scope per machine.

Migration 0005 moves everything and rewrites nothing: capture bytes
determine reconciliation hashes. It asserts `.taskless/rules/` holds no
loose .yml first, since that path is both the new root and the pre-0004
flat location. Vale matchers split by their `tskl) rule` breadcrumb;
one without a breadcrumb is left in place and reported rather than
guessed at or dropped, because it is a user's hand edit.

Deleted with their layouts: the legacy read paths (unreachable — the
migrations run before any read), filesystem/sgconfig.ts (assembly
replaces it), rules/owner.ts and `rule verify` (an id does not name one
rule; the path form has no ambiguity case).

0004's StylesPath docstring now explains both layouts. It said
`StylesPath = rules` is wrong, which was true flat and is exactly
backwards here — a bare contradiction invites a future reader to revert
it.

Typecheck and build clean. Tests still assert the old layout; they are
next.
Caught by migrating a real 0004 project and running check: ast-grep kept
reporting, Vale went silent.

A Vale check is named <style>.<rule>, and the style is whatever
StylesPath points at. Flat, that was `rules`, so assignments read
`rules.<id> = YES`. Per-rule directories make each rule its own style,
so the same rule is `<id>.<id>`. The migration moved the assignment
across verbatim, producing a config Vale parses happily, reports nothing
for, and exits zero on — every migrated rule silently disabled, which is
precisely the failure this layout exists to prevent.

0005 now rewrites the assignment as it splits, and the mapper collapses
`<id>.<id>` to `<id>` so findings carry the id the user filed under. It
only collapses when both halves match: a `<style>.<check>` where they
differ came from a user's own Vale styles, and halving that name would
report under an id identifying nothing.

Verified end to end — a 0004 project migrates and both engines fire.
Fixture project, engine dispatch, and the Vale suites now build rules as
directories. Adds assemble.test.ts covering what task 2.5 asks for: the
StylesPath header, sorted rule order, byte-identical output across runs,
each rule's own matcher order preserved, and provenance tagging.

Also makes 0005 scaffold rules/<engine>/ — it prunes 0004's directories,
so without this a freshly migrated project had no rules tree at all,
every engine reporting absent and nowhere obvious to write a first rule.

Deletes sg-committed-config.test.ts (the committed config it covers is
now assembled) and rule-verify-dispatch.test.ts (rule verify is gone;
its replacement lands with the path-addressed commands).

55 failures left, all layout paths in the remaining suites.
Also fixes buildIsolatingConfig, which still enabled `rules.<id>` under
a StylesPath that no longer produces that name — the same silent-disable
the migration hit, one file over. Verification would have reported every
rule as not firing.

migrate-engine-layout now asserts the end state of 0004+0005 rather than
0004's intermediate layout: both are unreleased and ship together, so a
user upgrades through the pair and never sees the middle.

90 failures down to 30.
Retires the two tests describing behavior that no longer exists — a rule
at the pre-0004 flat path, and merging two layouts with de-duplication.
There is one tree now, so there is no overlap to collapse.

Drops the `rule verify --anonymous` case with the command it covered.

30 failures down to 21.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant