@dowel/dowel 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -30
- package/bin/commands/lint.mjs +89 -0
- package/bin/commands/new-rule.mjs +172 -0
- package/bin/commands/setup.mjs +432 -0
- package/bin/dowel.mjs +44 -76
- package/package.json +9 -7
- package/skills/dowel-setup/SKILL.md +73 -0
- package/templates/fixture.passing.ts.tmpl +8 -0
- package/templates/fixture.violation.ts.tmpl +10 -0
- package/templates/rule.ast.ts.tmpl +34 -0
- package/templates/rule.regex.ts.tmpl +40 -0
- package/templates/rule.sql.ts.tmpl +45 -0
package/README.md
CHANGED
|
@@ -1,12 +1,72 @@
|
|
|
1
1
|
# dowel
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
**A deterministic harness for coding agents.** dowel compiles your
|
|
4
|
+
architecture into rules and checks the *whole codebase* on every run, so an AI
|
|
5
|
+
agent — or a human — gets the same non-negotiable feedback every time: what's
|
|
6
|
+
allowed, what isn't, and *why*.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
```bash
|
|
9
|
+
npm i -D @dowel/dowel && npx dowel setup
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Why
|
|
15
|
+
|
|
16
|
+
Coding agents amplify whatever patterns already exist in a repo — including the
|
|
17
|
+
uneven ones. Boundaries with exceptions get copied as exceptions; the codebase
|
|
18
|
+
drifts. The usual answer is more prose — `CLAUDE.md`, `AGENTS.md`, `.cursorrules`
|
|
19
|
+
— but prose is **non-deterministic**: the model may read it, may not, and it
|
|
20
|
+
rots as the codebase grows.
|
|
21
|
+
|
|
22
|
+
> *"Agents write the code; linters write the law. Agents iterate against
|
|
23
|
+
> deterministic feedback far better than they iterate against vibes."*
|
|
24
|
+
|
|
25
|
+
dowel moves the gate **into the structure of the codebase**. Architecture
|
|
26
|
+
becomes compiled, checkable law:
|
|
27
|
+
|
|
28
|
+
- **Deterministic** — the same diagnostics every run, not a suggestion the model
|
|
29
|
+
weighs. Exit code is non-zero on any `error`, so it gates CI and agent loops.
|
|
30
|
+
- **Whole-codebase, every time** — no context window to overflow, no
|
|
31
|
+
`AGENTS.md` to keep in sync. The rules *are* the spec.
|
|
32
|
+
- **Diagnostics + intent** — every finding states the rule's intent, so the
|
|
33
|
+
agent reading it learns the pattern and fixes it, then carries the pattern
|
|
34
|
+
forward. The codebase teaches the next agent.
|
|
35
|
+
|
|
36
|
+
## What it checks that a type-checker can't
|
|
37
|
+
|
|
38
|
+
- **Architecture boundaries** — vertical-slice structure, cross-slice imports,
|
|
39
|
+
layer purity (no SQL in services, no `Response` in repos, Result at write
|
|
40
|
+
boundaries), folder vocabulary, ownership.
|
|
41
|
+
- **Raw SQL against a real schema** — dowel builds a **shadow database** from
|
|
42
|
+
your migrations (Postgres *or* SQLite/D1) and `PREPARE`s every query against
|
|
43
|
+
it, catching drift, missing indexes, and N+1s a type-checker never sees. If
|
|
44
|
+
the shadow is unreachable it says so, loudly — never a silent pass.
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm i -D @dowel/dowel
|
|
50
|
+
npx dowel setup # detects stack/DB/structure, picks a profile,
|
|
51
|
+
# writes arch-lint.toml, scaffolds rules/
|
|
52
|
+
npm run lint:arch # or: npx dowel
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`dowel setup` reads your repo and recommends a **profile** — a curated rule set
|
|
56
|
+
for your stack:
|
|
57
|
+
|
|
58
|
+
| Profile | For |
|
|
59
|
+
|---------|-----|
|
|
60
|
+
| `node-postgres` | Node/Hono + Postgres |
|
|
61
|
+
| `cloudflare-d1` | Cloudflare Workers + D1 (SQLite) |
|
|
62
|
+
| `sanity-nextjs` | Next.js + Sanity (no SQL) |
|
|
63
|
+
| `generic` / `portable` | any TypeScript / any language baseline |
|
|
64
|
+
|
|
65
|
+
## Authoring rules
|
|
66
|
+
|
|
67
|
+
Portable rules ship in the packs. Codebase-specific rules live *with your
|
|
68
|
+
codebase* as TypeScript — type-checked by your own `tsc`, scaffolded by
|
|
69
|
+
`dowel new-rule`:
|
|
10
70
|
|
|
11
71
|
```ts
|
|
12
72
|
// rules/no-sql-outside-repos.ts
|
|
@@ -15,41 +75,34 @@ import { defineRule } from "@dowel/dowel";
|
|
|
15
75
|
export default defineRule({
|
|
16
76
|
id: "NO_SQL_OUTSIDE_REPOS",
|
|
17
77
|
intent: "raw SQL lives only in repos/ — services compose, repos query",
|
|
18
|
-
check(project) {
|
|
19
|
-
return project
|
|
20
|
-
.files()
|
|
78
|
+
check(project, ctx) {
|
|
79
|
+
return project.files()
|
|
21
80
|
.filter((f) => /\bselect\b/i.test(f.text) && !f.relPath.includes("/repos/"))
|
|
22
|
-
.map((f) => ({ severity: "error", file: f.path, line: 1,
|
|
81
|
+
.map((f) => ({ severity: "error", file: f.path, line: 1,
|
|
82
|
+
message: "move SQL into a repo" }));
|
|
23
83
|
},
|
|
24
84
|
});
|
|
25
85
|
```
|
|
26
86
|
|
|
27
|
-
## Install
|
|
28
|
-
|
|
29
87
|
```bash
|
|
30
|
-
|
|
88
|
+
dowel new-rule NO_SQL_OUTSIDE_REPOS # scaffolds the rule + passing/violation fixtures
|
|
31
89
|
```
|
|
32
90
|
|
|
33
|
-
|
|
34
|
-
(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
## Use
|
|
38
|
-
|
|
39
|
-
Add an `arch-lint.toml` and a `rules/` dir, then:
|
|
91
|
+
Every rule declares **when it fires** (scope — e.g. exempt tests), **which
|
|
92
|
+
variant** (typed options), and is toggled per-repo in `arch-lint.toml` — so
|
|
93
|
+
calibration is data, not a fork of the rule.
|
|
40
94
|
|
|
41
|
-
|
|
42
|
-
// package.json
|
|
43
|
-
"scripts": { "lint:arch": "dowel" }
|
|
44
|
-
```
|
|
95
|
+
## How it's built
|
|
45
96
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
97
|
+
The Rust engine ships as a prebuilt native Node addon: `npm i` pulls exactly one
|
|
98
|
+
`@dowel/dowel-<platform>` package via `optionalDependencies` — no Rust
|
|
99
|
+
toolchain, no post-install compile, no committed binary. Borrows hard-won
|
|
100
|
+
patterns from `ruff`/`oxc` (speed), `sqlc` (DB-checked SQL), and `Squawk`
|
|
101
|
+
(migration safety).
|
|
50
102
|
|
|
51
|
-
|
|
103
|
+
> **Platform support:** `darwin-arm64` ships today; the Linux/Windows binaries
|
|
104
|
+
> land via the CI build matrix. On an unsupported platform the loader tells you.
|
|
52
105
|
|
|
53
106
|
## License
|
|
54
107
|
|
|
55
|
-
MIT
|
|
108
|
+
MIT OR Apache-2.0
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// dowel lint — the architecture linter runner (plan 005 §2; consumer-side).
|
|
2
|
+
//
|
|
3
|
+
// Extracted verbatim from the original bin/dowel.mjs body (plan 007 §0 router
|
|
4
|
+
// refactor). Loads every TS rule under the rules dir, runs the native packs +
|
|
5
|
+
// all TS rules through the native addon, prints diagnostics, exits non-zero on
|
|
6
|
+
// any error.
|
|
7
|
+
//
|
|
8
|
+
// Invoked two ways, both routed here with identical argv semantics:
|
|
9
|
+
// dowel lint [flags] → run(argv) with argv = everything after `lint`
|
|
10
|
+
// dowel [flags|root] → run(argv) with argv = everything after `dowel`
|
|
11
|
+
//
|
|
12
|
+
// Flags: [--root <project>] [--config <arch-lint.toml>] [--rules <dir>] [--no-db]
|
|
13
|
+
// Defaults: --config ./arch-lint.toml, --rules <dir-of-config>/rules,
|
|
14
|
+
// --root current working directory.
|
|
15
|
+
|
|
16
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
17
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const opts = { root: null, config: null, rules: null, withOracle: true };
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
if (a === "--root") opts.root = argv[++i];
|
|
25
|
+
else if (a === "--config") opts.config = argv[++i];
|
|
26
|
+
else if (a === "--rules") opts.rules = argv[++i];
|
|
27
|
+
else if (a === "--no-db") opts.withOracle = false;
|
|
28
|
+
else if (a === "-h" || a === "--help") {
|
|
29
|
+
console.log(
|
|
30
|
+
"dowel lint — architecture linter\n\n" +
|
|
31
|
+
"Usage: dowel [lint] [--root <project>] [--config <arch-lint.toml>] [--rules <dir>] [--no-db]\n",
|
|
32
|
+
);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
} else if (!a.startsWith("-") && opts.root === null) {
|
|
35
|
+
// bare positional → project root (back-compat with run.ts argv[2])
|
|
36
|
+
opts.root = a;
|
|
37
|
+
} else {
|
|
38
|
+
console.error(`dowel: unknown argument '${a}'`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return opts;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function abs(p) {
|
|
46
|
+
return isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function format(d, root) {
|
|
50
|
+
const rel = d.file.startsWith(root) ? "." + d.file.slice(root.length) : d.file;
|
|
51
|
+
return `${d.severity}\t${rel}:${d.line}\t[${d.rule}] ${d.message}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Run the lint. `argv` is the args following `dowel` (or `dowel lint`). */
|
|
55
|
+
export async function run(argv) {
|
|
56
|
+
const { runLint } = await import("../../dist/runtime.js");
|
|
57
|
+
|
|
58
|
+
const opts = parseArgs(argv);
|
|
59
|
+
|
|
60
|
+
const configPath = abs(opts.config ?? "arch-lint.toml");
|
|
61
|
+
if (!existsSync(configPath)) {
|
|
62
|
+
console.error(`dowel: config not found: ${configPath}`);
|
|
63
|
+
console.error(" pass --config <path/to/arch-lint.toml> or run from its directory");
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const rulesDir = abs(opts.rules ?? join(dirname(configPath), "rules"));
|
|
68
|
+
const root = abs(opts.root ?? process.cwd());
|
|
69
|
+
const withOracle = opts.withOracle && process.env.ARCH_LINT_NO_DB !== "1";
|
|
70
|
+
|
|
71
|
+
const rules = [];
|
|
72
|
+
if (existsSync(rulesDir)) {
|
|
73
|
+
for (const name of readdirSync(rulesDir).sort()) {
|
|
74
|
+
if (!name.endsWith(".ts") || name.endsWith(".d.ts")) continue;
|
|
75
|
+
const mod = await import(pathToFileURL(join(rulesDir, name)).href);
|
|
76
|
+
if (mod.default) rules.push(mod.default);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const diags = runLint({ root, configPath, withOracle, rules });
|
|
81
|
+
for (const d of diags) console.log(format(d, root));
|
|
82
|
+
|
|
83
|
+
const errors = diags.filter((d) => d.severity === "error").length;
|
|
84
|
+
const warnings = diags.filter((d) => d.severity === "warn").length;
|
|
85
|
+
console.log(
|
|
86
|
+
`\narch-lint-ts: ${errors} error(s), ${warnings} warning(s) [${rules.length} TS rules]`,
|
|
87
|
+
);
|
|
88
|
+
process.exit(errors > 0 ? 1 : 0);
|
|
89
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// dowel new-rule <CODE> — scaffold a defineRule module + co-located fixtures
|
|
2
|
+
// (plan 007 §1/§2). Removes copy-paste-from-an-existing-rule: emits a typed
|
|
3
|
+
// `check` skeleton (regex | ast | sql), optional optionsSchema + allowlist
|
|
4
|
+
// wiring, and passing/violation fixture stubs with an inline expectation.
|
|
5
|
+
//
|
|
6
|
+
// dowel new-rule <CODE> [--rules <dir>] [--kind regex|ast|sql]
|
|
7
|
+
// [--options] [--no-fixtures] [--force]
|
|
8
|
+
//
|
|
9
|
+
// <CODE> is SCREAMING_SNAKE; the file is kebab-cased (NO_TAG_ONLY → no-tag-only).
|
|
10
|
+
// Fixtures co-locate under rules/<kebab>/fixtures/{passing,violation}/ — the
|
|
11
|
+
// engine skips any /fixtures/ path so the bad sample never trips a real lint.
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
const TEMPLATES = resolve(fileURLToPath(import.meta.url), "../../../templates");
|
|
18
|
+
const KINDS = new Set(["regex", "ast", "sql"]);
|
|
19
|
+
|
|
20
|
+
function abs(p) {
|
|
21
|
+
return isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** SCREAMING_SNAKE → kebab-case (NO_TAG_ONLY_ERROR → no-tag-only-error). */
|
|
25
|
+
function toKebab(code) {
|
|
26
|
+
return code.toLowerCase().replace(/_/g, "-");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const opts = {
|
|
31
|
+
code: null,
|
|
32
|
+
rules: null,
|
|
33
|
+
config: null,
|
|
34
|
+
kind: "regex",
|
|
35
|
+
options: false,
|
|
36
|
+
fixtures: true,
|
|
37
|
+
force: false,
|
|
38
|
+
};
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a === "--rules") opts.rules = argv[++i];
|
|
42
|
+
else if (a === "--config") opts.config = argv[++i];
|
|
43
|
+
else if (a === "--kind") opts.kind = argv[++i];
|
|
44
|
+
else if (a === "--options") opts.options = true;
|
|
45
|
+
else if (a === "--no-fixtures") opts.fixtures = false;
|
|
46
|
+
else if (a === "--force") opts.force = true;
|
|
47
|
+
else if (a === "-h" || a === "--help") {
|
|
48
|
+
console.log(
|
|
49
|
+
"dowel new-rule <CODE> — scaffold a rule + fixtures\n\n" +
|
|
50
|
+
"Usage: dowel new-rule <CODE> [--rules <dir>] [--kind regex|ast|sql]\n" +
|
|
51
|
+
" [--options] [--no-fixtures] [--force]\n",
|
|
52
|
+
);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
} else if (!a.startsWith("-") && opts.code === null) {
|
|
55
|
+
opts.code = a;
|
|
56
|
+
} else {
|
|
57
|
+
console.error(`dowel new-rule: unknown argument '${a}'`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return opts;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The option-block fills for a rule, keyed by whether --options was passed. */
|
|
65
|
+
function optionFills(code, kebab, withOptions) {
|
|
66
|
+
if (!withOptions) {
|
|
67
|
+
return {
|
|
68
|
+
OPTIONS_IFACE: "",
|
|
69
|
+
TYPE_PARAM: "",
|
|
70
|
+
OPTIONS_SCHEMA: "",
|
|
71
|
+
CTX_PARAM: "",
|
|
72
|
+
OPTIONS_USE: "",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
OPTIONS_IFACE:
|
|
77
|
+
`\n/** Per-rule options (plan 004 §2). Override via \`[rules.${code}]\`. */\n` +
|
|
78
|
+
`interface Options {\n /** Sidecar allowlist path (\`token # reason\` per line). */\n allowlist: string;\n}\n`,
|
|
79
|
+
TYPE_PARAM: "<Options>",
|
|
80
|
+
OPTIONS_SCHEMA:
|
|
81
|
+
` optionsSchema: {\n` +
|
|
82
|
+
` type: "object",\n` +
|
|
83
|
+
` properties: {\n` +
|
|
84
|
+
` allowlist: { type: "string", default: "allowlists/${kebab}.txt" },\n` +
|
|
85
|
+
` },\n` +
|
|
86
|
+
` },\n`,
|
|
87
|
+
CTX_PARAM: ", ctx",
|
|
88
|
+
OPTIONS_USE:
|
|
89
|
+
` const { allowlist } = ctx.options;\n` +
|
|
90
|
+
` // \`allow\` exempts tokens; \`violations\` flags any missing \`# reason\`.\n` +
|
|
91
|
+
` const { allow, violations } = ctx.loadAllowlist(allowlist);\n` +
|
|
92
|
+
` out.push(...violations);\n` +
|
|
93
|
+
` void allow; // TODO: skip a finding when its token is in \`allow\`\n`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function render(tmpl, fills) {
|
|
98
|
+
return tmpl.replace(/\{\{(\w+)\}\}/g, (_, key) =>
|
|
99
|
+
key in fills ? fills[key] : `{{${key}}}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeFile(path, content, force) {
|
|
104
|
+
if (existsSync(path) && !force) {
|
|
105
|
+
console.error(`dowel new-rule: refusing to overwrite ${path} (pass --force)`);
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
109
|
+
writeFileSync(path, content);
|
|
110
|
+
console.log(` created ${path}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function run(argv) {
|
|
114
|
+
const opts = parseArgs(argv);
|
|
115
|
+
|
|
116
|
+
if (!opts.code) {
|
|
117
|
+
console.error("dowel new-rule: missing <CODE> (e.g. NO_RAW_FETCH)");
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(opts.code)) {
|
|
121
|
+
console.error(`dowel new-rule: <CODE> must be SCREAMING_SNAKE_CASE, got '${opts.code}'`);
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
if (!KINDS.has(opts.kind)) {
|
|
125
|
+
console.error(`dowel new-rule: --kind must be one of ${[...KINDS].join(", ")}`);
|
|
126
|
+
process.exit(2);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const code = opts.code;
|
|
130
|
+
const kebab = toKebab(code);
|
|
131
|
+
|
|
132
|
+
// Resolve the rules dir the same way the lint runner does: explicit --rules,
|
|
133
|
+
// else <dir-of-config>/rules, else ./rules.
|
|
134
|
+
let rulesDir;
|
|
135
|
+
if (opts.rules) {
|
|
136
|
+
rulesDir = abs(opts.rules);
|
|
137
|
+
} else if (opts.config) {
|
|
138
|
+
rulesDir = join(dirname(abs(opts.config)), "rules");
|
|
139
|
+
} else if (existsSync(abs("arch-lint.toml"))) {
|
|
140
|
+
rulesDir = join(dirname(abs("arch-lint.toml")), "rules");
|
|
141
|
+
} else {
|
|
142
|
+
rulesDir = abs("rules");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const fills = {
|
|
146
|
+
CODE: code,
|
|
147
|
+
KEBAB: kebab,
|
|
148
|
+
INTENT: "TODO: one line — why this rule exists (shown to the agent reading the diagnostic)",
|
|
149
|
+
...optionFills(code, kebab, opts.options),
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const ruleTmpl = readFileSync(join(TEMPLATES, `rule.${opts.kind}.ts.tmpl`), "utf8");
|
|
153
|
+
const rulePath = join(rulesDir, `${kebab}.ts`);
|
|
154
|
+
writeFile(rulePath, render(ruleTmpl, fills), opts.force);
|
|
155
|
+
|
|
156
|
+
if (opts.fixtures) {
|
|
157
|
+
const passTmpl = readFileSync(join(TEMPLATES, "fixture.passing.ts.tmpl"), "utf8");
|
|
158
|
+
const violTmpl = readFileSync(join(TEMPLATES, "fixture.violation.ts.tmpl"), "utf8");
|
|
159
|
+
const fxDir = join(rulesDir, kebab, "fixtures");
|
|
160
|
+
writeFile(join(fxDir, "passing", "ok.ts"), render(passTmpl, fills), opts.force);
|
|
161
|
+
writeFile(join(fxDir, "violation", "bad.ts"), render(violTmpl, fills), opts.force);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
console.log(
|
|
165
|
+
`\nScaffolded ${code} (${opts.kind}${opts.options ? ", options" : ""}). Next:\n` +
|
|
166
|
+
` 1. fill in the TODOs in ${kebab}.ts (pattern + message + intent)\n` +
|
|
167
|
+
` 2. make the violation fixture actually fire the rule\n` +
|
|
168
|
+
(opts.options
|
|
169
|
+
? ` 3. create the allowlist file referenced in optionsSchema (if used)\n`
|
|
170
|
+
: ""),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// dowel setup — deterministic onboarding scaffolder (plan 007 §3 / §5.3a).
|
|
2
|
+
//
|
|
3
|
+
// Inverse of "read the docs and wire it yourself": detect a repo's shape,
|
|
4
|
+
// recommend a profile, write arch-lint.toml, scaffold rules/ + allowlists/, wire
|
|
5
|
+
// the package.json devDep + lint:arch script, and do an initial lint so the user
|
|
6
|
+
// sees real diagnostics. The reasoning layer (ambiguous-profile choice, first
|
|
7
|
+
// domain rules) is the thin skill wrapper (skills/dowel-setup) that drives this
|
|
8
|
+
// primitive — see §5.3b.
|
|
9
|
+
//
|
|
10
|
+
// dowel setup [--root <dir>] [--profile <name>] [--migrations <dir>]
|
|
11
|
+
// [--dialect postgres|sqlite] [--yes] [--dry-run] [--baseline]
|
|
12
|
+
//
|
|
13
|
+
// Profiles (plan 006): node-postgres | cloudflare-d1 | sanity-nextjs | generic.
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
21
|
+
const PROFILES = new Set(["node-postgres", "cloudflare-d1", "sanity-nextjs", "generic"]);
|
|
22
|
+
|
|
23
|
+
function abs(p) {
|
|
24
|
+
return isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(argv) {
|
|
28
|
+
const o = {
|
|
29
|
+
root: null,
|
|
30
|
+
profile: null,
|
|
31
|
+
migrations: null,
|
|
32
|
+
dialect: null,
|
|
33
|
+
yes: false,
|
|
34
|
+
dryRun: false,
|
|
35
|
+
baseline: false,
|
|
36
|
+
};
|
|
37
|
+
for (let i = 0; i < argv.length; i++) {
|
|
38
|
+
const a = argv[i];
|
|
39
|
+
if (a === "--root") o.root = argv[++i];
|
|
40
|
+
else if (a === "--profile") o.profile = argv[++i];
|
|
41
|
+
else if (a === "--migrations") o.migrations = argv[++i];
|
|
42
|
+
else if (a === "--dialect") o.dialect = argv[++i];
|
|
43
|
+
else if (a === "--yes" || a === "-y") o.yes = true;
|
|
44
|
+
else if (a === "--dry-run") o.dryRun = true;
|
|
45
|
+
else if (a === "--baseline") o.baseline = true;
|
|
46
|
+
else if (a === "-h" || a === "--help") {
|
|
47
|
+
console.log(
|
|
48
|
+
"dowel setup — onboard a repo onto dowel\n\n" +
|
|
49
|
+
"Usage: dowel setup [--root <dir>] [--profile <name>] [--migrations <dir>]\n" +
|
|
50
|
+
" [--dialect postgres|sqlite] [--yes] [--dry-run] [--baseline]\n\n" +
|
|
51
|
+
"Profiles: node-postgres | cloudflare-d1 | sanity-nextjs | generic\n",
|
|
52
|
+
);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
} else if (!a.startsWith("-") && o.root === null) {
|
|
55
|
+
o.root = a;
|
|
56
|
+
} else {
|
|
57
|
+
console.error(`dowel setup: unknown argument '${a}'`);
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return o;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---- detection (port of crates/cli/src/init.rs + stack/DB signals) ----------
|
|
65
|
+
|
|
66
|
+
function subdirs(dir) {
|
|
67
|
+
try {
|
|
68
|
+
return readdirSync(dir)
|
|
69
|
+
.map((n) => join(dir, n))
|
|
70
|
+
.filter((p) => {
|
|
71
|
+
try {
|
|
72
|
+
return statSync(p).isDirectory();
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
.sort();
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function baseName(p) {
|
|
84
|
+
return p.replace(/\/+$/, "").split("/").pop() ?? p;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function detectFeaturesRoot(root) {
|
|
88
|
+
for (const c of ["src/features", "features", "src/modules", "src/app"]) {
|
|
89
|
+
if (existsSync(join(root, c)) && statSync(join(root, c)).isDirectory()) return c;
|
|
90
|
+
}
|
|
91
|
+
return "src/features";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function detectMigrationsDir(root, override) {
|
|
95
|
+
if (override) return override;
|
|
96
|
+
for (const c of ["migrations", "db/migrations", "sql/migrations", "api/migrations"]) {
|
|
97
|
+
if (existsSync(join(root, c))) return c;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function detectArchitecture(root, featuresRoot) {
|
|
103
|
+
const froot = join(root, featuresRoot);
|
|
104
|
+
const groups = new Set();
|
|
105
|
+
const sliceDirs = [];
|
|
106
|
+
if (existsSync(froot)) {
|
|
107
|
+
for (const group of subdirs(froot)) {
|
|
108
|
+
const slices = subdirs(group);
|
|
109
|
+
if (slices.length) {
|
|
110
|
+
groups.add(baseName(group));
|
|
111
|
+
sliceDirs.push(...slices);
|
|
112
|
+
} else {
|
|
113
|
+
sliceDirs.push(group);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// NOTE: we deliberately DO NOT derive slice_vocab from the folder names found
|
|
118
|
+
// inside slices — on a real repo that yields garbage (component/util names
|
|
119
|
+
// like ConditionEditor, calculate-score, …) rather than the canonical VSA
|
|
120
|
+
// buckets. The generated config omits slice_vocab so the vsa pack's default
|
|
121
|
+
// bucket vocabulary applies (contracts/public/api/services/repos/types/
|
|
122
|
+
// components/routes/hooks/workflow/client.ts). A consumer adds a genuine
|
|
123
|
+
// recurring bucket by hand if needed.
|
|
124
|
+
return {
|
|
125
|
+
featuresRoot,
|
|
126
|
+
groups: [...groups].sort(),
|
|
127
|
+
sliceCount: sliceDirs.length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectTablePrefixes(root, migrationsDir) {
|
|
132
|
+
const prefixes = new Set();
|
|
133
|
+
if (!migrationsDir) return prefixes;
|
|
134
|
+
const mdir = join(root, migrationsDir);
|
|
135
|
+
const re = /create\s+table\s+(?:if\s+not\s+exists\s+)?([a-z]+)_/gi;
|
|
136
|
+
let entries = [];
|
|
137
|
+
try {
|
|
138
|
+
entries = readdirSync(mdir);
|
|
139
|
+
} catch {
|
|
140
|
+
return prefixes;
|
|
141
|
+
}
|
|
142
|
+
for (const name of entries) {
|
|
143
|
+
if (!name.endsWith(".sql")) continue;
|
|
144
|
+
try {
|
|
145
|
+
const sql = readFileSync(join(mdir, name), "utf8");
|
|
146
|
+
for (const m of sql.matchAll(re)) prefixes.add(`${m[1]}_`);
|
|
147
|
+
} catch {
|
|
148
|
+
/* unreadable migration — skip */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return prefixes;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readJson(p) {
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function detectStack(root) {
|
|
163
|
+
const pkg = readJson(join(root, "package.json")) ?? {};
|
|
164
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
165
|
+
const has = (name) => name in deps;
|
|
166
|
+
const hasWrangler =
|
|
167
|
+
existsSync(join(root, "wrangler.toml")) || existsSync(join(root, "wrangler.jsonc"));
|
|
168
|
+
let wranglerHasD1 = false;
|
|
169
|
+
for (const f of ["wrangler.toml", "wrangler.jsonc", "wrangler.json"]) {
|
|
170
|
+
const p = join(root, f);
|
|
171
|
+
if (existsSync(p)) {
|
|
172
|
+
try {
|
|
173
|
+
if (/d1_databases|"d1"|\bd1\b/i.test(readFileSync(p, "utf8"))) wranglerHasD1 = true;
|
|
174
|
+
} catch {
|
|
175
|
+
/* skip */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
pkg,
|
|
181
|
+
hasSanity: has("sanity") || has("@sanity/client") || has("next-sanity"),
|
|
182
|
+
hasNext: has("next"),
|
|
183
|
+
hasReact: has("react"),
|
|
184
|
+
hasHono: has("hono"),
|
|
185
|
+
hasPostgres: has("pg") || has("postgres") || has("@neondatabase/serverless"),
|
|
186
|
+
hasWrangler,
|
|
187
|
+
wranglerHasD1,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Lead's mapping (§ greenlight). Returns { profile, dialect, reason }. */
|
|
192
|
+
function recommendProfile(stack, migrationsDir) {
|
|
193
|
+
if (stack.hasWrangler && (stack.wranglerHasD1 || !stack.hasPostgres)) {
|
|
194
|
+
return {
|
|
195
|
+
profile: "cloudflare-d1",
|
|
196
|
+
dialect: "sqlite",
|
|
197
|
+
reason: "wrangler config present (Cloudflare Workers + D1 → SQLite dialect)",
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
if (stack.hasSanity || stack.hasNext) {
|
|
201
|
+
return {
|
|
202
|
+
profile: "sanity-nextjs",
|
|
203
|
+
dialect: null,
|
|
204
|
+
reason: `${stack.hasSanity ? "Sanity" : "Next.js"} detected (frontend profile, no DB oracle)`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (stack.hasPostgres || migrationsDir) {
|
|
208
|
+
return {
|
|
209
|
+
profile: "node-postgres",
|
|
210
|
+
dialect: "postgres",
|
|
211
|
+
reason: stack.hasPostgres
|
|
212
|
+
? "Postgres client dependency detected"
|
|
213
|
+
: "SQL migrations directory detected",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
profile: "generic",
|
|
218
|
+
dialect: null,
|
|
219
|
+
reason: "no decisive stack signal — defaulting to generic (skill should confirm)",
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---- emit -------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
function tomlArray(values) {
|
|
226
|
+
return `[${values.map((v) => `"${v}"`).join(", ")}]`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function renderConfig({ profile, dialect, arch, prefixes, migrationsDir }) {
|
|
230
|
+
const L = [];
|
|
231
|
+
L.push("# arch-lint.toml — generated by `dowel setup`. Review every line.");
|
|
232
|
+
L.push("# This is detected, not decided: trim/confirm before committing.\n");
|
|
233
|
+
L.push(`profile = "${profile}"\n`);
|
|
234
|
+
|
|
235
|
+
L.push("[architecture]");
|
|
236
|
+
L.push(`features_root = "${arch.featuresRoot}"`);
|
|
237
|
+
if (arch.groups.length) L.push(`groups = ${tomlArray(arch.groups)}`);
|
|
238
|
+
// slice_vocab intentionally omitted — the vsa pack default applies. Setting it
|
|
239
|
+
// from detected folder names produced garbage; declare it by hand only if your
|
|
240
|
+
// slices genuinely use a different bucket vocabulary.
|
|
241
|
+
L.push("# slice_vocab = [...] # omitted: uses the vsa pack default bucket vocab");
|
|
242
|
+
if (prefixes.length) L.push(`table_prefixes = ${tomlArray(prefixes)}`);
|
|
243
|
+
L.push("");
|
|
244
|
+
|
|
245
|
+
if (dialect === "postgres") {
|
|
246
|
+
L.push("[database]");
|
|
247
|
+
L.push("# Engine self-builds a throwaway shadow Postgres from `migrations` each run:");
|
|
248
|
+
L.push("# connect to server_url (a maintenance DB), recreate the shadow, apply every");
|
|
249
|
+
L.push("# migration, validate SQL rules. Unreachable => SQL rules skip; rest still run.");
|
|
250
|
+
L.push('dialect = "postgres"');
|
|
251
|
+
L.push('server_url = "host=localhost port=5432 user=postgres password=postgres dbname=postgres"');
|
|
252
|
+
L.push(`migrations = "${migrationsDir ?? "migrations"}"`);
|
|
253
|
+
L.push("");
|
|
254
|
+
} else if (dialect === "sqlite") {
|
|
255
|
+
L.push("[database]");
|
|
256
|
+
L.push("# SQLite (Cloudflare D1) shadow is built in-memory from `migrations`; no url.");
|
|
257
|
+
L.push('dialect = "sqlite"');
|
|
258
|
+
L.push(`migrations = "${migrationsDir ?? "migrations"}"`);
|
|
259
|
+
L.push("");
|
|
260
|
+
} else {
|
|
261
|
+
L.push("# No [database] block — this profile runs no SQL-drift oracle.\n");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
L.push("# [severity] # optional: per-rule severity overrides");
|
|
265
|
+
L.push("# [rules] # optional: disable/only within the profile");
|
|
266
|
+
L.push("# [[rule]] # optional: project-declared regex rules (no recompile)");
|
|
267
|
+
return L.join("\n") + "\n";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const RULES_README =
|
|
271
|
+
"# rules/\n\n" +
|
|
272
|
+
"Project-specific rules authored in TypeScript via `defineRule` from " +
|
|
273
|
+
"`@dowel/dowel`.\nScaffold one with `dowel new-rule <CODE>` — it generates the " +
|
|
274
|
+
"rule stub plus\nco-located `<rule>/fixtures/{passing,violation}/` samples.\n\n" +
|
|
275
|
+
"Each top-level `rules/*.ts` is loaded as a rule; `fixtures/` paths are skipped\n" +
|
|
276
|
+
"by the engine walk, so violation samples never trip a real lint.\n";
|
|
277
|
+
|
|
278
|
+
const ALLOWLISTS_README =
|
|
279
|
+
"# allowlists/\n\n" +
|
|
280
|
+
"Sidecar allowlists for option-bearing rules (`token # reason` per line).\n" +
|
|
281
|
+
"Each entry REQUIRES a `# reason` comment — a bare token is itself a violation.\n" +
|
|
282
|
+
"Reference one from a rule's `[rules.<CODE>]` table in arch-lint.toml.\n";
|
|
283
|
+
|
|
284
|
+
function wirePackageJson(root, configRel, dryRun, log) {
|
|
285
|
+
const pkgPath = join(root, "package.json");
|
|
286
|
+
const pkg = readJson(pkgPath);
|
|
287
|
+
if (!pkg) {
|
|
288
|
+
log(` (no package.json at ${pkgPath} — skipping devDep + lint:arch wiring)`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
// Pin the devDep to the running engine's version when resolvable.
|
|
292
|
+
let version = "latest";
|
|
293
|
+
const ownPkg = readJson(resolve(SELF, "../../../package.json"));
|
|
294
|
+
if (ownPkg?.version) version = `^${ownPkg.version}`;
|
|
295
|
+
|
|
296
|
+
pkg.devDependencies ??= {};
|
|
297
|
+
pkg.scripts ??= {};
|
|
298
|
+
const changes = [];
|
|
299
|
+
if (pkg.devDependencies["@dowel/dowel"] == null) {
|
|
300
|
+
pkg.devDependencies["@dowel/dowel"] = version;
|
|
301
|
+
changes.push(`devDependencies["@dowel/dowel"] = "${version}"`);
|
|
302
|
+
}
|
|
303
|
+
const lintCmd = `dowel --config ${configRel}`;
|
|
304
|
+
if (pkg.scripts["lint:arch"] == null) {
|
|
305
|
+
pkg.scripts["lint:arch"] = lintCmd;
|
|
306
|
+
changes.push(`scripts["lint:arch"] = "${lintCmd}"`);
|
|
307
|
+
}
|
|
308
|
+
if (typeof pkg.scripts.typecheck === "string" && !pkg.scripts.typecheck.includes("lint:arch")) {
|
|
309
|
+
pkg.scripts.typecheck = `npm run lint:arch && ${pkg.scripts.typecheck}`;
|
|
310
|
+
changes.push("scripts.typecheck now runs lint:arch first");
|
|
311
|
+
}
|
|
312
|
+
if (!changes.length) {
|
|
313
|
+
log(" package.json already wired (devDep + lint:arch present)");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
for (const c of changes) log(` package.json: ${c}`);
|
|
317
|
+
if (!dryRun) writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function writeIfAbsent(path, content, dryRun, log) {
|
|
321
|
+
if (existsSync(path)) {
|
|
322
|
+
log(` exists, kept: ${path}`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
log(` ${dryRun ? "would create" : "created"} ${path}`);
|
|
326
|
+
if (!dryRun) {
|
|
327
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
328
|
+
writeFileSync(path, content);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function run(argv) {
|
|
333
|
+
const o = parseArgs(argv);
|
|
334
|
+
const root = abs(o.root ?? process.cwd());
|
|
335
|
+
const dryRun = o.dryRun;
|
|
336
|
+
const log = (m) => console.log(m);
|
|
337
|
+
|
|
338
|
+
if (o.profile && !PROFILES.has(o.profile)) {
|
|
339
|
+
console.error(`dowel setup: --profile must be one of ${[...PROFILES].join(", ")}`);
|
|
340
|
+
process.exit(2);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
log(`dowel setup — ${dryRun ? "DRY RUN, " : ""}root: ${root}\n`);
|
|
344
|
+
|
|
345
|
+
// 1. detect
|
|
346
|
+
const featuresRoot = detectFeaturesRoot(root);
|
|
347
|
+
const migrationsDir = detectMigrationsDir(root, o.migrations);
|
|
348
|
+
const arch = detectArchitecture(root, featuresRoot);
|
|
349
|
+
const prefixes = [...detectTablePrefixes(root, migrationsDir)].sort();
|
|
350
|
+
const stack = detectStack(root);
|
|
351
|
+
|
|
352
|
+
// 2. recommend profile (explicit --profile wins)
|
|
353
|
+
const rec = recommendProfile(stack, migrationsDir);
|
|
354
|
+
const profile = o.profile ?? rec.profile;
|
|
355
|
+
let dialect = o.dialect ?? rec.dialect;
|
|
356
|
+
// sanity-nextjs never carries a dialect; node-postgres/cloudflare-d1 imply one.
|
|
357
|
+
if (profile === "sanity-nextjs") dialect = null;
|
|
358
|
+
else if (profile === "node-postgres") dialect = dialect ?? "postgres";
|
|
359
|
+
else if (profile === "cloudflare-d1") dialect = dialect ?? "sqlite";
|
|
360
|
+
|
|
361
|
+
log("Detected:");
|
|
362
|
+
log(` features_root = ${featuresRoot} (${arch.sliceCount} slice(s), ${arch.groups.length} group(s))`);
|
|
363
|
+
log(` migrations = ${migrationsDir ?? "(none)"}`);
|
|
364
|
+
log(` stack = ${describeStack(stack)}`);
|
|
365
|
+
log(
|
|
366
|
+
` → profile = ${profile}${o.profile ? " (forced)" : ""}` +
|
|
367
|
+
`${dialect ? `, dialect=${dialect}` : ""}`,
|
|
368
|
+
);
|
|
369
|
+
log(` reason: ${o.profile ? "explicit --profile" : rec.reason}\n`);
|
|
370
|
+
|
|
371
|
+
// 3. write arch-lint.toml
|
|
372
|
+
const configPath = join(root, "arch-lint.toml");
|
|
373
|
+
const configRel = relative(root, configPath) || "arch-lint.toml";
|
|
374
|
+
const configBody = renderConfig({ profile, dialect, arch, prefixes, migrationsDir });
|
|
375
|
+
if (existsSync(configPath)) {
|
|
376
|
+
log(` exists, kept: ${configPath} (delete it to regenerate)`);
|
|
377
|
+
} else {
|
|
378
|
+
log(` ${dryRun ? "would write" : "wrote"} ${configPath}`);
|
|
379
|
+
if (!dryRun) writeFileSync(configPath, configBody);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 4. scaffold rules/ + allowlists/
|
|
383
|
+
writeIfAbsent(join(root, "rules", "README.md"), RULES_README, dryRun, log);
|
|
384
|
+
writeIfAbsent(join(root, "allowlists", "README.md"), ALLOWLISTS_README, dryRun, log);
|
|
385
|
+
|
|
386
|
+
// 5. wire package.json
|
|
387
|
+
wirePackageJson(root, configRel, dryRun, log);
|
|
388
|
+
|
|
389
|
+
// 6. initial lint (skip in dry-run)
|
|
390
|
+
if (!dryRun) {
|
|
391
|
+
log("\nRunning initial lint…\n");
|
|
392
|
+
const r = spawnSync(process.execPath, [SELF_DOWEL(), "--config", configPath, "--root", root], {
|
|
393
|
+
stdio: "inherit",
|
|
394
|
+
});
|
|
395
|
+
log(`\n(initial lint exit ${r.status ?? "n/a"} — non-zero just means findings exist)`);
|
|
396
|
+
} else {
|
|
397
|
+
log("\n(dry-run: skipped initial lint)");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 7. baseline guidance — baseline is not yet a `dowel` subcommand (it lives in
|
|
401
|
+
// the arch-lint Rust CLI). Surface the next step rather than fail.
|
|
402
|
+
if (o.baseline) {
|
|
403
|
+
log(
|
|
404
|
+
"\n--baseline: grandfathering is not yet wired into the `dowel` bin " +
|
|
405
|
+
"(plan: ship `dowel baseline`).\n For now use the `arch-lint baseline` CLI, " +
|
|
406
|
+
"or commit the current findings as your starting point.",
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
log(
|
|
411
|
+
"\nNext:\n" +
|
|
412
|
+
" 1. review arch-lint.toml (the [architecture]/[database] were detected, not decided)\n" +
|
|
413
|
+
" 2. `dowel new-rule <CODE>` to add your first project rule\n" +
|
|
414
|
+
" 3. add `npm run lint:arch` to CI\n",
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function describeStack(s) {
|
|
419
|
+
const tags = [];
|
|
420
|
+
if (s.hasSanity) tags.push("sanity");
|
|
421
|
+
if (s.hasNext) tags.push("next");
|
|
422
|
+
if (s.hasReact && !s.hasNext) tags.push("react");
|
|
423
|
+
if (s.hasHono) tags.push("hono");
|
|
424
|
+
if (s.hasWrangler) tags.push(s.wranglerHasD1 ? "wrangler+d1" : "wrangler");
|
|
425
|
+
if (s.hasPostgres) tags.push("postgres");
|
|
426
|
+
return tags.length ? tags.join(", ") : "(no recognised deps)";
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Path to the dowel bin entry (sibling-of-sibling of this command module). */
|
|
430
|
+
function SELF_DOWEL() {
|
|
431
|
+
return resolve(SELF, "../../dowel.mjs");
|
|
432
|
+
}
|
package/bin/dowel.mjs
CHANGED
|
@@ -1,22 +1,23 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// dowel — the architecture linter CLI (plan 005 §2;
|
|
2
|
+
// dowel — the architecture linter CLI (plan 005 §2 runner; plan 007 §0 router).
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
4
|
+
// ONE bin entry. argv[2] selects a subcommand; anything else (a flag, a bare
|
|
5
|
+
// project root, or nothing) falls through to `lint` so the original surface
|
|
6
|
+
// (`dowel`, `dowel ./proj`, `dowel --config x`) is byte-for-byte preserved.
|
|
6
7
|
//
|
|
7
|
-
//
|
|
8
|
-
// dowel
|
|
8
|
+
// dowel [lint] [--root <p>] [--config <toml>] [--rules <dir>] [--no-db]
|
|
9
|
+
// dowel new-rule <CODE> [--rules <dir>] [--kind regex|ast|sql] [--options]
|
|
10
|
+
// dowel setup [...] (plan 007 §3 — blocked on profiles, not yet wired)
|
|
11
|
+
// dowel test [...] (plan 007 §2 — not yet wired)
|
|
9
12
|
//
|
|
10
|
-
//
|
|
11
|
-
// --root current working directory.
|
|
13
|
+
// Subcommand modules live in bin/commands/<name>.mjs, each exporting run(argv).
|
|
12
14
|
|
|
13
|
-
import {
|
|
14
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
15
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
16
|
|
|
17
|
-
// Consumer rules are authored in TypeScript; importing them
|
|
18
|
-
// type-stripping. It's unflagged on Node
|
|
19
|
-
// If this process can't strip types,
|
|
17
|
+
// Consumer rules are authored in TypeScript; importing them (lint) and writing
|
|
18
|
+
// them (new-rule) both need Node's type-stripping. It's unflagged on Node
|
|
19
|
+
// >= 22.18 / 23, flagged before that. If this process can't strip types,
|
|
20
|
+
// re-exec ourselves with the flag once — covers every subcommand.
|
|
20
21
|
if (!process.features?.typescript && !process.env.__DOWEL_TS) {
|
|
21
22
|
const { spawnSync } = await import("node:child_process");
|
|
22
23
|
const self = fileURLToPath(import.meta.url);
|
|
@@ -28,71 +29,38 @@ if (!process.features?.typescript && !process.env.__DOWEL_TS) {
|
|
|
28
29
|
process.exit(r.status ?? 1);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
// Subcommand dispatch. Only verbs with a real bin/commands/<verb>.mjs are
|
|
33
|
+
// IMPLEMENTED — routing to a missing module would crash with
|
|
34
|
+
// ERR_MODULE_NOT_FOUND, so a verb is NEVER reserved before its module exists.
|
|
35
|
+
// PLANNED verbs are recognised only to print a deliberate "not yet implemented"
|
|
36
|
+
// (never a crash). `lint` is implemented so `dowel lint --config x` routes
|
|
37
|
+
// cleanly; any non-verb token (a flag, a bare path, nothing) falls through to
|
|
38
|
+
// lint(), keeping legacy `dowel --config x` / `dowel .` unchanged.
|
|
39
|
+
const IMPLEMENTED = new Set(["lint", "new-rule", "setup"]);
|
|
40
|
+
const PLANNED = new Set(["test", "init", "doctor", "baseline"]);
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
for (let i = 0; i < argv.length; i++) {
|
|
36
|
-
const a = argv[i];
|
|
37
|
-
if (a === "--root") opts.root = argv[++i];
|
|
38
|
-
else if (a === "--config") opts.config = argv[++i];
|
|
39
|
-
else if (a === "--rules") opts.rules = argv[++i];
|
|
40
|
-
else if (a === "--no-db") opts.withOracle = false;
|
|
41
|
-
else if (a === "-h" || a === "--help") {
|
|
42
|
-
console.log(
|
|
43
|
-
"dowel — architecture linter\n\n" +
|
|
44
|
-
"Usage: dowel [--root <project>] [--config <arch-lint.toml>] [--rules <dir>] [--no-db]\n",
|
|
45
|
-
);
|
|
46
|
-
process.exit(0);
|
|
47
|
-
} else if (!a.startsWith("-") && opts.root === null) {
|
|
48
|
-
// bare positional → project root (back-compat with run.ts argv[2])
|
|
49
|
-
opts.root = a;
|
|
50
|
-
} else {
|
|
51
|
-
console.error(`dowel: unknown argument '${a}'`);
|
|
52
|
-
process.exit(2);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return opts;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function abs(p) {
|
|
59
|
-
return isAbsolute(p) ? p : resolve(process.cwd(), p);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const opts = parseArgs(process.argv.slice(2));
|
|
63
|
-
|
|
64
|
-
const configPath = abs(opts.config ?? "arch-lint.toml");
|
|
65
|
-
if (!existsSync(configPath)) {
|
|
66
|
-
console.error(`dowel: config not found: ${configPath}`);
|
|
67
|
-
console.error(" pass --config <path/to/arch-lint.toml> or run from its directory");
|
|
68
|
-
process.exit(2);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const rulesDir = abs(opts.rules ?? join(dirname(configPath), "rules"));
|
|
72
|
-
const root = abs(opts.root ?? process.cwd());
|
|
73
|
-
const withOracle = opts.withOracle && process.env.ARCH_LINT_NO_DB !== "1";
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
const sub = argv[0];
|
|
74
44
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
45
|
+
if (sub === "-h" || sub === "--help") {
|
|
46
|
+
console.log(
|
|
47
|
+
"dowel — architecture linter\n\n" +
|
|
48
|
+
"Usage:\n" +
|
|
49
|
+
" dowel [lint] [--root <p>] [--config <toml>] [--rules <dir>] [--no-db]\n" +
|
|
50
|
+
" dowel new-rule <CODE> [--rules <dir>] [--kind regex|ast|sql] [--options]\n" +
|
|
51
|
+
" dowel setup [--root <dir>] [--profile <name>] [--dry-run]\n",
|
|
52
|
+
);
|
|
53
|
+
process.exit(0);
|
|
84
54
|
}
|
|
85
55
|
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
56
|
+
if (IMPLEMENTED.has(sub)) {
|
|
57
|
+
const mod = await import(`./commands/${sub}.mjs`);
|
|
58
|
+
await mod.run(argv.slice(1));
|
|
59
|
+
} else if (PLANNED.has(sub)) {
|
|
60
|
+
console.error(`dowel: '${sub}' is not yet implemented`);
|
|
61
|
+
process.exit(1);
|
|
62
|
+
} else {
|
|
63
|
+
// Fallthrough: legacy lint surface. Pass everything after `dowel`.
|
|
64
|
+
const { run } = await import("./commands/lint.mjs");
|
|
65
|
+
await run(argv);
|
|
89
66
|
}
|
|
90
|
-
|
|
91
|
-
const rules = await loadRules();
|
|
92
|
-
const diags = runLint({ root, configPath, withOracle, rules });
|
|
93
|
-
for (const d of diags) console.log(format(d, root));
|
|
94
|
-
|
|
95
|
-
const errors = diags.filter((d) => d.severity === "error").length;
|
|
96
|
-
const warnings = diags.filter((d) => d.severity === "warn").length;
|
|
97
|
-
console.log(`\narch-lint-ts: ${errors} error(s), ${warnings} warning(s) [${rules.length} TS rules]`);
|
|
98
|
-
process.exit(errors > 0 ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dowel/dowel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "dowel — a compiled, opinionated architecture linter. TS-native rule authoring over a Rust engine (Project, AST, shadow-DB SQL oracle).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
"files": [
|
|
27
27
|
"dist/",
|
|
28
28
|
"bin/",
|
|
29
|
+
"templates/",
|
|
30
|
+
"skills/",
|
|
29
31
|
"README.md",
|
|
30
32
|
"LICENSE"
|
|
31
33
|
],
|
|
@@ -57,12 +59,12 @@
|
|
|
57
59
|
"@types/node": "^22.0.0"
|
|
58
60
|
},
|
|
59
61
|
"optionalDependencies": {
|
|
60
|
-
"@dowel/dowel-darwin-arm64": "0.
|
|
61
|
-
"@dowel/dowel-darwin-x64": "0.
|
|
62
|
-
"@dowel/dowel-linux-x64-gnu": "0.
|
|
63
|
-
"@dowel/dowel-linux-arm64-gnu": "0.
|
|
64
|
-
"@dowel/dowel-linux-x64-musl": "0.
|
|
65
|
-
"@dowel/dowel-win32-x64-msvc": "0.
|
|
62
|
+
"@dowel/dowel-darwin-arm64": "0.3.1",
|
|
63
|
+
"@dowel/dowel-darwin-x64": "0.3.1",
|
|
64
|
+
"@dowel/dowel-linux-x64-gnu": "0.3.1",
|
|
65
|
+
"@dowel/dowel-linux-arm64-gnu": "0.3.1",
|
|
66
|
+
"@dowel/dowel-linux-x64-musl": "0.3.1",
|
|
67
|
+
"@dowel/dowel-win32-x64-msvc": "0.3.1"
|
|
66
68
|
},
|
|
67
69
|
"publishConfig": {
|
|
68
70
|
"access": "public"
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dowel-setup
|
|
3
|
+
description: Onboard a repository onto the dowel architecture linter. Explore the codebase, recommend a profile, generate arch-lint.toml, scaffold rules/ + allowlists/, wire the lint:arch script, and propose the first project rules. Use when a user asks to "set up dowel", "add the arch linter to this repo", or "onboard <repo> onto dowel".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# dowel setup
|
|
7
|
+
|
|
8
|
+
You are onboarding a repository onto **dowel** — a compiled architecture linter.
|
|
9
|
+
This skill is the *reasoning* layer over the deterministic `dowel setup` CLI
|
|
10
|
+
primitive (it ships in `@dowel/dowel`). The CLI detects and templates; you bring
|
|
11
|
+
judgement the CLI can't: ambiguous-profile resolution and the first domain rules.
|
|
12
|
+
It is model-agnostic — every step is a shell command, so it runs identically
|
|
13
|
+
under Claude Code, Codex, or Antigravity.
|
|
14
|
+
|
|
15
|
+
## Profiles (the menu)
|
|
16
|
+
|
|
17
|
+
A profile is a fixed pack list. Pick exactly one:
|
|
18
|
+
|
|
19
|
+
| Profile | Packs | Oracle | `[database].dialect` | Use when |
|
|
20
|
+
|-----------------|--------------------------|--------|----------------------|-----------------------------------|
|
|
21
|
+
| `node-postgres` | generic, vsa, sql | yes | postgres | Node/Hono + Postgres migrations |
|
|
22
|
+
| `cloudflare-d1` | generic, vsa, sql | yes | sqlite | Cloudflare Workers + D1 |
|
|
23
|
+
| `sanity-nextjs` | generic, vsa, react | no | (none) | Next.js / Sanity frontend |
|
|
24
|
+
| `generic` | generic, vsa, sql, react | — | optional | mixed / unclear — the safe default|
|
|
25
|
+
|
|
26
|
+
## Procedure
|
|
27
|
+
|
|
28
|
+
1. **Dry-run the detector first — never write blind:**
|
|
29
|
+
```
|
|
30
|
+
npx dowel setup --dry-run
|
|
31
|
+
```
|
|
32
|
+
Read its `Detected:` block (features_root, migrations, stack) and its
|
|
33
|
+
recommended profile + reason.
|
|
34
|
+
|
|
35
|
+
2. **Confirm or override the profile with judgement.** The CLI maps:
|
|
36
|
+
wrangler+D1 → `cloudflare-d1`; Sanity/Next → `sanity-nextjs`; Postgres deps or
|
|
37
|
+
SQL migrations → `node-postgres`; otherwise `generic`. When signals are mixed
|
|
38
|
+
(e.g. a Workers app that also renders React, or a monorepo), the CLI defaults
|
|
39
|
+
to `generic` and flags it — THIS is where you decide. Inspect:
|
|
40
|
+
- `package.json` deps, `wrangler.jsonc` (D1 bindings), `migrations/` dialect,
|
|
41
|
+
`src/features/` vs a layered `src/services|repos|apis` tree.
|
|
42
|
+
- If the repo is layered (not VSA), say so — dowel's vsa pack assumes slices;
|
|
43
|
+
recommend `generic` and note the slices migration is a separate effort.
|
|
44
|
+
|
|
45
|
+
3. **Apply** (writes arch-lint.toml, scaffolds rules/ + allowlists/, wires
|
|
46
|
+
package.json, runs an initial lint):
|
|
47
|
+
```
|
|
48
|
+
npx dowel setup --profile <chosen> --yes
|
|
49
|
+
```
|
|
50
|
+
For Postgres, edit the generated `[database].server_url` to the project's real
|
|
51
|
+
shadow/maintenance DB connection.
|
|
52
|
+
|
|
53
|
+
4. **Read the initial lint output** the command prints. Summarise the top
|
|
54
|
+
violation classes for the user — that is the value: real diagnostics on their
|
|
55
|
+
code, immediately.
|
|
56
|
+
|
|
57
|
+
5. **Propose the first 2–3 project rules** for this stack and scaffold them:
|
|
58
|
+
```
|
|
59
|
+
npx dowel new-rule <CODE> --kind regex|ast|sql [--options]
|
|
60
|
+
```
|
|
61
|
+
Pick rules that encode *this* codebase's conventions (the profile packs cover
|
|
62
|
+
the portable ones). Fill the TODOs in each generated `rules/<kebab>.ts` and
|
|
63
|
+
make its `fixtures/violation/bad.ts` actually fire.
|
|
64
|
+
|
|
65
|
+
6. **Offer to baseline.** Existing violations can be grandfathered so only NEW
|
|
66
|
+
errors fail the build (`arch-lint baseline` today; `dowel baseline` once
|
|
67
|
+
wired). Recommend this for any repo with a non-trivial initial count.
|
|
68
|
+
|
|
69
|
+
## Rules
|
|
70
|
+
|
|
71
|
+
- Never invent a profile or pack — only the four above exist.
|
|
72
|
+
- Never write arch-lint.toml by hand; let `dowel setup` generate it, then edit.
|
|
73
|
+
- Don't commit; leave that to the user. Report what changed and the lint count.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Fixture: PASSING case for {{CODE}}.
|
|
2
|
+
//
|
|
3
|
+
// This file MUST NOT trigger {{CODE}}. It documents the shape the rule
|
|
4
|
+
// considers correct. Lives under rules/{{KEBAB}}/fixtures/ — the engine skips
|
|
5
|
+
// any path containing /fixtures/, so this sample is never flagged by a real
|
|
6
|
+
// lint. Edit to a realistic clean example.
|
|
7
|
+
|
|
8
|
+
export const ok = true;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Fixture: VIOLATION case for {{CODE}}.
|
|
2
|
+
//
|
|
3
|
+
// This file MUST trigger {{CODE}} exactly where annotated. The `// ~ error:`
|
|
4
|
+
// marker on the line below is the inline expectation (ESLint RuleTester style)
|
|
5
|
+
// that `dowel test` (plan 007 §2) will assert against — keep it on the line the
|
|
6
|
+
// diagnostic points at. Replace the placeholder with code that actually fires
|
|
7
|
+
// the rule.
|
|
8
|
+
|
|
9
|
+
// ~ error: {{CODE}} TODO: the message the rule emits for this line
|
|
10
|
+
const TODO_REPLACE_ME = "make this line violate {{CODE}}";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// {{CODE}} — {{INTENT}}
|
|
2
|
+
//
|
|
3
|
+
// Scaffolded by `dowel new-rule {{CODE}} --kind ast`. Walks each file's
|
|
4
|
+
// tree-sitter AST (flat node list, parent pointers) and emits a Finding per
|
|
5
|
+
// offending node. Replace the kind check + message; delete this header.
|
|
6
|
+
|
|
7
|
+
import { defineRule, type AstNode, type Finding } from "@dowel/dowel";
|
|
8
|
+
{{OPTIONS_IFACE}}
|
|
9
|
+
/** TODO: the node `kind` this rule targets (e.g. "call_expression"). */
|
|
10
|
+
const TARGET_KIND = "TODO_REPLACE_ME";
|
|
11
|
+
|
|
12
|
+
export default defineRule{{TYPE_PARAM}}({
|
|
13
|
+
id: "{{CODE}}",
|
|
14
|
+
intent: "{{INTENT}}",
|
|
15
|
+
group: "structure",
|
|
16
|
+
appliesTo: ["typescript", "tsx"],
|
|
17
|
+
{{OPTIONS_SCHEMA}} check(project{{CTX_PARAM}}) {
|
|
18
|
+
const out: Finding[] = [];
|
|
19
|
+
{{OPTIONS_USE}} for (const file of project.files()) {
|
|
20
|
+
const nodes: AstNode[] = project.astOf(file.path);
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
if (node.kind !== TARGET_KIND) continue;
|
|
23
|
+
// TODO: refine the condition that makes `node` a violation.
|
|
24
|
+
out.push({
|
|
25
|
+
severity: "error",
|
|
26
|
+
file: file.path,
|
|
27
|
+
line: node.startRow + 1,
|
|
28
|
+
message: "TODO: actionable message — what to do instead",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// {{CODE}} — {{INTENT}}
|
|
2
|
+
//
|
|
3
|
+
// Scaffolded by `dowel new-rule {{CODE}} --kind regex`. A pure text/regex rule:
|
|
4
|
+
// scan every parsed file's source, emit one Finding per match. Replace PATTERN
|
|
5
|
+
// and the message with the real rule; delete this header comment when done.
|
|
6
|
+
|
|
7
|
+
import { defineRule, type Finding } from "@dowel/dowel";
|
|
8
|
+
{{OPTIONS_IFACE}}
|
|
9
|
+
// TODO: the pattern this rule forbids (or requires). The placeholder sentinel
|
|
10
|
+
// is split so this stub never matches its OWN source while rules/ is linted —
|
|
11
|
+
// replace the whole expression with a normal literal regex (e.g. /console\.log/g).
|
|
12
|
+
const PATTERN = new RegExp("TODO_" + "REPLACE_ME", "g");
|
|
13
|
+
|
|
14
|
+
/** 1-based line number of a byte offset in `src`. */
|
|
15
|
+
function lineAt(src: string, off: number): number {
|
|
16
|
+
let n = 1;
|
|
17
|
+
for (let i = 0; i < off && i < src.length; i++) if (src.charCodeAt(i) === 10) n++;
|
|
18
|
+
return n;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default defineRule{{TYPE_PARAM}}({
|
|
22
|
+
id: "{{CODE}}",
|
|
23
|
+
intent: "{{INTENT}}",
|
|
24
|
+
group: "structure",
|
|
25
|
+
appliesTo: ["typescript", "tsx"],
|
|
26
|
+
{{OPTIONS_SCHEMA}} check(project{{CTX_PARAM}}) {
|
|
27
|
+
const out: Finding[] = [];
|
|
28
|
+
{{OPTIONS_USE}} for (const file of project.files()) {
|
|
29
|
+
for (const m of file.text.matchAll(PATTERN)) {
|
|
30
|
+
out.push({
|
|
31
|
+
severity: "error",
|
|
32
|
+
file: file.path,
|
|
33
|
+
line: lineAt(file.text, m.index!),
|
|
34
|
+
message: "TODO: actionable message — what to do instead",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// {{CODE}} — {{INTENT}}
|
|
2
|
+
//
|
|
3
|
+
// Scaffolded by `dowel new-rule {{CODE}} --kind sql`. Extracts SQL from source
|
|
4
|
+
// and validates it against the shadow DB via project.prepareSql. The shadow is
|
|
5
|
+
// self-built from [database].migrations; if it is absent/unreachable the
|
|
6
|
+
// prepare is `skipped` and this rule emits nothing (every other rule still
|
|
7
|
+
// runs). Replace the SQL extraction + message; delete this header.
|
|
8
|
+
|
|
9
|
+
import { defineRule, type Finding } from "@dowel/dowel";
|
|
10
|
+
{{OPTIONS_IFACE}}
|
|
11
|
+
// TODO: pull candidate SQL out of source — e.g. tagged-template `sql`...``.
|
|
12
|
+
// The placeholder sentinel is split so this stub never matches its OWN source
|
|
13
|
+
// while rules/ is linted — replace with your real extraction regex.
|
|
14
|
+
const SQL_RE = new RegExp("TODO_" + "REPLACE_ME", "gs");
|
|
15
|
+
|
|
16
|
+
function lineAt(src: string, off: number): number {
|
|
17
|
+
let n = 1;
|
|
18
|
+
for (let i = 0; i < off && i < src.length; i++) if (src.charCodeAt(i) === 10) n++;
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default defineRule{{TYPE_PARAM}}({
|
|
23
|
+
id: "{{CODE}}",
|
|
24
|
+
intent: "{{INTENT}}",
|
|
25
|
+
group: "structure",
|
|
26
|
+
appliesTo: ["typescript", "tsx"],
|
|
27
|
+
{{OPTIONS_SCHEMA}} check(project{{CTX_PARAM}}) {
|
|
28
|
+
const out: Finding[] = [];
|
|
29
|
+
{{OPTIONS_USE}} for (const file of project.files()) {
|
|
30
|
+
for (const m of file.text.matchAll(SQL_RE)) {
|
|
31
|
+
const sql = m[1] ?? m[0];
|
|
32
|
+
const res = project.prepareSql(sql);
|
|
33
|
+
if (res.skipped) return out; // no shadow DB → SQL rules skip, never error
|
|
34
|
+
if (res.ok) continue;
|
|
35
|
+
out.push({
|
|
36
|
+
severity: "error",
|
|
37
|
+
file: file.path,
|
|
38
|
+
line: lineAt(file.text, m.index!),
|
|
39
|
+
message: `SQL does not validate against the schema: ${res.error ?? "unknown"}`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
},
|
|
45
|
+
});
|