feat: one directory per rule, and path-addressed verify/test - #103
Draft
thecodedrift wants to merge 9 commits into
Draft
feat: one directory per rule, and path-addressed verify/test#103thecodedrift wants to merge 9 commits into
thecodedrift wants to merge 9 commits into
Conversation
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.
This was referenced Aug 14, 2026
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.iniand 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:
sgandruntimerules are also split between arules/tree and a parallelrule-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:
checkassembles the Vale and ast-grep configs from the per-rule tree and gitignores them.rule verify <id>is replaced by path-addressedverify <path>(required components) andtest <path>(tests), withverifyrunning as a prerequisite layer oftest.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 achecktest so it can't drift.The dot on
.tests/is load-bearing, and measuredtests/inside the rule dirmissing field 'language'__tests__/.tests/.ymltestDir: …/.testsworks; snapshots land insideruleDirsrecurses and parses every.ymlbeneath as a rule, so a plaintests/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>.ymlresolves as check<id>.<id>only under aStylesPathnaming its parent; underStylesPath = .it resolves to nothing. This reverses the note in migration0004— correct for the flat layout, backwards for this one — so the task list rewrites that docstring rather than deleting it.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.
0005layers on0004; 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 --strictwill flag thecli-agent-authoringdelta until #102 archives — that capability is introduced there.Spec-only. Implementation follows on approval.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3