Skip to content

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

Draft
thecodedrift wants to merge 4 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 4 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.
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