Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .agents/skills/taskless/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: taskless
description: |
Use for any Taskless task. Trigger when the user mentions Taskless by name,
or when their request involves the .taskless/ directory or files in it
(rules, rule-tests, rule-metadata).
(rules, rule-metadata).

Specifically:
- "create/add/write a taskless rule for X"
Expand Down
25 changes: 25 additions & 0 deletions .changeset/stale-installed-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"@taskless/cli": patch
---

The installed `.taskless/README.md` and Taskless skill no longer describe a
layout two migrations old.

Both named `rule-tests/`, a directory `0005` deletes, and the README described
rules as living under `sg/rules/` and `vale/rules/` rather than the current
`rules/<engine>/<id>/`. The skill line is the one that mattered most: it is a
trigger description, so it taught an agent to look in a directory the migration
had removed.

`0001` writes the README on every run and says it "overwrites stale content
from older versions", which is true and not sufficient. Migrations only run
above the recorded version, so a project already at 5 never ran `0001` again
and kept its stale copy permanently. Migration `0006` rewrites it, so an
existing project gets a correct description rather than only new installs.

The README's layout section is now derived from the rule layout table instead
of described beside it, so the words cannot disagree with the directories they
describe. `LATEST_SCHEMA_VERSION` is exported for the same reason: tests
hardcoded the current version in ten places, and the version matrix listed
prior versions literally, so each new migration silently stopped covering the
version it had just made prior.
10 changes: 8 additions & 2 deletions .taskless/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,11 @@ npx @taskless/cli@latest check
- `.env.local.json` - Local authentication credentials (git-ignored)
- `skills/` - Canonical Taskless skill content; tool directories hold thin stubs that delegate here (managed by Taskless)
- `commands/` - Canonical Taskless command content (managed by Taskless)
- `rules/` - Generated ast-grep rules (managed by Taskless)
- `rule-tests/` - Rule tests containing pass/fail examples for your rules

Every rule is one directory, `rules/<engine>/<id>/`, holding
everything that defines it. Its test cases sit inside it as
`.tests/`:

- `rules/sg/<id>/` - run by ast-grep; holds `<id>.yml`
- `rules/vale/<id>/` - run by vale-runner; holds `<id>.yml`, `.vale.ini`
- `rules/runtime/<id>/` - run by runtime-harness; holds `check.ts`, `captures/`
4 changes: 2 additions & 2 deletions .taskless/taskless.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": 5,
"version": 6,
"install": {
"targets": {
".taskless": {
Expand All @@ -21,7 +21,7 @@
"mode": "reference"
}
},
"cliVersion": "0.11.0-self",
"cliVersion": "0.11.0",
"onboarded": true
},
"rules": {
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/filesystem/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import installMigration from "./migrations/0002-install";
import dropInstalledAt from "./migrations/0003-drop-installed-at";
import valeEngine from "./migrations/0004-vale-engine";
import ruleDirectories from "./migrations/0005-rule-directories";
import refreshReadme from "./migrations/0006-refresh-readme";

export interface TasklessInstallTarget {
skills?: string[];
Expand Down Expand Up @@ -71,6 +72,7 @@ const migrations: Migrations = {
"3": dropInstalledAt,
"4": valeEngine,
"5": ruleDirectories,
"6": refreshReadme,
};

/** Global flag that downgrades a too-new scaffold from an error to a skip. */
Expand Down Expand Up @@ -263,6 +265,47 @@ export interface RunMigrationsOptions {
allowVersionMismatches?: boolean;
}

// ---------------------------------------------------------------------------
// A MIGRATION OWNS EVERYTHING UNDER `.taskless/`, INCLUDING THE PROSE.
//
// If a migration moves, renames or deletes anything in that directory, the
// files describing the directory are wrong from that moment, and the migration
// is the only commit that knows it. Every other mechanism — a reviewer, a
// linter, someone noticing — runs later than the moment the fact changed.
//
// That is not hypothetical here. `0004` and `0005` relocated every rule and
// deleted `rule-tests/`, and the installed `README.md` and skill description
// went on naming the old tree for two releases. `0001` rewrites the README on
// every run, which sounds like it covers this and does not: migrations only run
// ABOVE the recorded version, so a project that is already current never
// rewrites anything. Reaching those projects took `0006`.
//
// So when you write a migration that changes this directory's shape:
//
// 1. Update whatever describes it. `0001`'s README body derives its layout
// section from the rule layout table, so a table change carries; anything
// written as prose does not.
// 2. Refresh this repository's own `.taskless/` and commit it, since we
// install Taskless on ourselves. `installed-documentation.test.ts` fails
// if you forget.
// 3. Ask whether already-current projects need the change. If they do, the
// only thing that reaches them is a new version, because nothing below
// the recorded one runs again.
//
// ---------------------------------------------------------------------------

/**
Comment thread
thecodedrift marked this conversation as resolved.
* The schema version a current CLI migrates a project to.
*
* Derived from the migration map rather than declared beside it, so adding a
* migration cannot leave a constant behind. Exported because tests kept
* hardcoding the number, which made every schema bump a hunt for literals and
* turned "reaches the latest version" into "reaches 5" — an assertion that
* silently stops meaning what it was written to mean.
*/
export const LATEST_SCHEMA_VERSION: number =
sortedMigrations(migrations).at(-1)?.[0] ?? 0;

/**
* Run any pending migrations against the .taskless/ directory.
* Reads the current version from taskless.json and runs migrations
Expand Down
50 changes: 44 additions & 6 deletions packages/cli/src/filesystem/migrations/0001-init.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";

import {
ENGINES,
ENGINE_LAYOUTS,
RULES_DIRECTORY,
RULE_TESTS_DIRECTORY,
} from "../../rules/layout";

import { addToGitignore } from "../gitignore";
import type { Migration } from "../types";
import { buildInvocation } from "../../util/invocation";
Expand Down Expand Up @@ -73,15 +80,46 @@ ${usageBlock(specifier)}
- \`skills/\` - Canonical Taskless skill content; tool directories hold thin stubs that delegate here (managed by Taskless)
- \`commands/\` - Canonical Taskless command content (managed by Taskless)

Rules are partitioned by the engine that runs them. Each engine directory holds
that tool's own native config, its \`rules/\`, and its \`rule-tests/\`:

- \`sg/\` - ast-grep: \`sgconfig.yml\`, generated rules (managed by Taskless), and their pass/fail test cases
- \`vale/\` - Vale prose rules: \`.vale.ini\`, \`rules/\`, and their pass/fail fixtures. Run by \`check\` alongside ast-grep
- \`runtime/\` - Rules that execute a \`check.ts\`, each in its own \`rules/<name>/\` directory
${layoutBlock()}
`;
}

/**
* The rule-layout section, DERIVED from the layout table rather than described
* beside it.
*
* This block used to be prose, and it described the pre-\`0004\` tree
* (\`sg/rules/\`, \`sg/rule-tests/\`) for two migrations after that tree stopped
* existing. The migration overwrites this file on every run, so a correctly
* migrated project was handed a stale description of its own directory, and
* `rule-tests/` was named as a directory \`0005\` deletes.
*
* Writing it from {@link ENGINES}, {@link RULES_DIRECTORY} and
* {@link RULE_TESTS_DIRECTORY} is the same move that fixed the seven stale
* layout comments: the words cannot disagree with the table, because they are
* the table. A future migration that relocates rules updates this text by
* changing the constants it already has to change.
*/
function layoutBlock(): string {
const engines = ENGINES.map((engine) => {
const layout = ENGINE_LAYOUTS[engine];
const pieces = [`\`${layout.ruleFile("<id>")}\``];
if (layout.ruleConfigFile !== undefined) {
pieces.push(`\`${layout.ruleConfigFile}\``);
}
if (layout.capturesDirectory !== undefined) {
pieces.push(`\`${layout.capturesDirectory}/\``);
}
return `- \`${RULES_DIRECTORY}/${engine}/<id>/\` - run by ${layout.executor}; holds ${pieces.join(", ")}`;
}).join("\n");

return `Every rule is one directory, \`${RULES_DIRECTORY}/<engine>/<id>/\`, holding
everything that defines it. Its test cases sit inside it as
\`${RULE_TESTS_DIRECTORY}/\`:

${engines}`;
}

const migration: Migration = async (directory) => {
// Always write README.md (overwrite stale content from older versions)
await writeFile(
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/filesystem/migrations/0006-refresh-readme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { join } from "node:path";
import { writeFile } from "node:fs/promises";

import { pinnedSpecifier } from "../../util/package-manager";
import { buildReadmeContent } from "./0001-init";
import type { Migration } from "../types";

/**
* Rewrite `.taskless/README.md`, because the copy on disk describes a tree that
* two migrations ago stopped existing.
*
* `0001` writes this file and says it "overwrites stale content from older
* versions", which is true and not sufficient: `runMigrations` only runs
* migrations ABOVE the recorded version, so a project already at 5 never runs
* `0001` again. Its README is frozen at whatever the CLI wrote when it last
* migrated, and for every project that reached 5 that text describes
* `sg/rules/` and `sg/rule-tests/` — a layout `0004` and `0005` dismantled, and
* a `rule-tests/` directory `0005` deletes outright.
*
* So the fix has to be a migration of its own. Correcting `0001`'s template
* reaches new installs and projects still catching up; nothing but a version
* bump reaches a project that is already current, which is most of them.
*
* A schema version spent on documentation is worth stating plainly rather than
* apologising for. The file is generated, not authored — `0001` overwrites it
* unconditionally and the header says "managed by Taskless" — so no user
* content is at risk, and the alternative is a wrong description of the
* project's own directory that never self-corrects.
*/
const migration: Migration = async (directory) => {
Comment thread
thecodedrift marked this conversation as resolved.
await writeFile(
join(directory, "README.md"),
buildReadmeContent(pinnedSpecifier()),
"utf8"
);
};

export default migration;
24 changes: 21 additions & 3 deletions packages/cli/test/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import { tmpdir } from "node:os";
import { describe, expect, it, beforeEach, afterEach } from "vitest";

import { ensureTasklessDirectory } from "../src/filesystem/directory";
import {
ENGINES,
RULES_DIRECTORY,
RULE_TESTS_DIRECTORY,
} from "../src/rules/layout";

const v0Fixture = resolve(import.meta.dirname, "fixtures/v0-production");

Expand Down Expand Up @@ -209,10 +214,23 @@ describe("v0 → v1 migration", () => {
join(temporaryDirectory, ".taskless", "README.md"),
"utf8"
);
// New README mentions rule-tests
expect(readme).toContain("rule-tests");
// New README mentions .env.local.json

// Describes the layout this migration LEAVES BEHIND, derived from the same
// table the migration moves files with. Asserted per engine, because the
// defect being fixed was a README that described a tree two migrations old
// while the run that wrote it was deleting that tree.
for (const engine of ENGINES) {
expect(readme, `${engine} is described`).toContain(
`${RULES_DIRECTORY}/${engine}/<id>/`
);
}
expect(readme).toContain(`${RULE_TESTS_DIRECTORY}/`);
expect(readme).toContain(".env.local.json");

// And NOT the directory this migration removes. `rule-tests/` was named
// here as an expectation, so the stale description had a passing test
// holding it in place — which is why nobody found it by running the suite.
expect(readme).not.toContain("rule-tests");
});

it("creates .gitignore that was missing in v0", async () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/test/init-no-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { promisify } from "node:util";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LATEST_SCHEMA_VERSION } from "../src/filesystem/migrate";

const execFileAsync = promisify(execFile);
const binPath = resolve(import.meta.dirname, "../dist/index.js");
Expand Down Expand Up @@ -157,7 +158,7 @@ describe("taskless init --no-interactive", () => {
await readFile(join(cwd, ".taskless", "taskless.json"), "utf8")
) as { version: number; install: Record<string, unknown> };

expect(manifest.version).toBe(5);
expect(manifest.version).toBe(LATEST_SCHEMA_VERSION);
expect(manifest.install).toBeDefined();
});

Expand Down
Loading
Loading