@aas-dev/stack 0.1.0 → 0.1.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/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/doctor.d.ts +5 -0
- package/dist/doctor.js +162 -0
- package/dist/explain.d.ts +4 -0
- package/dist/explain.js +48 -0
- package/dist/package-script.d.ts +3 -0
- package/dist/package-script.js +42 -0
- package/dist/plugin/aas.js +379 -0
- package/dist/program.js +19 -5
- package/dist/scaffold.d.ts +1 -1
- package/dist/scaffold.js +24 -3
- package/dist/verify.d.ts +1 -0
- package/dist/verify.js +38 -14
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.js +76 -0
- package/package.json +1 -1
- package/plugin/aas.mjs +31 -26
- package/templates/AGENTS.md +33 -18
- package/templates/pnpm-workspace.yaml +11 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kristoffer Aas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# `@aas-dev/stack`
|
|
2
|
+
|
|
3
|
+
The `aas` command. One toolchain: Vite+ (`vp`) for lint, format, test, and library build, and `@effect/tsgo` for the typecheck. The package version in this tree is `0.1.1`.
|
|
4
|
+
|
|
5
|
+
Node `>=22.18` and pnpm `11.3.0`.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
Put `pnpm-workspace.yaml` on disk before the first `pnpm install`. pnpm reads the overrides and `allowBuilds` while it builds the graph. `esbuild: true` lets the Vite+ install script run. Without it, pnpm `11.3.0` stops with `ERR_PNPM_IGNORED_BUILDS`. Extra `allowBuilds` keys are yours; `aas verify` does not call that drift.
|
|
10
|
+
|
|
11
|
+
```yaml
|
|
12
|
+
packages:
|
|
13
|
+
- "packages/*"
|
|
14
|
+
|
|
15
|
+
verifyDepsBeforeRun: error
|
|
16
|
+
|
|
17
|
+
# Exact versions are the pin. The release-age gate would refuse the Effect 4
|
|
18
|
+
# and oxlint versions this stack just chose, so it stays off.
|
|
19
|
+
minimumReleaseAge: 0
|
|
20
|
+
|
|
21
|
+
overrides:
|
|
22
|
+
"vite@*": "npm:@voidzero-dev/vite-plus-core@1.4.0"
|
|
23
|
+
"vitest@*": "5.0.5"
|
|
24
|
+
|
|
25
|
+
peerDependencyRules:
|
|
26
|
+
allowAny:
|
|
27
|
+
- vite
|
|
28
|
+
- vitest
|
|
29
|
+
allowedVersions:
|
|
30
|
+
vite: "*"
|
|
31
|
+
vitest: "*"
|
|
32
|
+
|
|
33
|
+
allowBuilds:
|
|
34
|
+
esbuild: true
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The `vite` override aliases Vite to `@voidzero-dev/vite-plus-core`. That package's own version is `1.4.0`, which is outside Vitest's `vite@^6 || ^7 || ^8` peer range. `peerDependencyRules` is what makes pnpm treat the aliased package as satisfying that peer. `allowAny` and `allowedVersions: "*"` are the pair Vite+ writes for this override.
|
|
38
|
+
|
|
39
|
+
`minimumReleaseAge: 0` lets pnpm fetch the Effect and Vite+ versions this stack pins. A project with no `packages/` directory still installs: the glob matches nothing and the root package is the project.
|
|
40
|
+
|
|
41
|
+
Depend on `effect`, `@effect/vitest`, and `@types/node` as well. The project imports them.
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pnpm add -D @aas-dev/stack@0.1.1 effect@4.0.6 @effect/vitest@4.0.6 @types/node@22.20.5
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
From a packed tarball the file name is `aas-dev-stack-0.1.1.tgz`:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"scripts": {
|
|
52
|
+
"verify": "aas verify"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@aas-dev/stack": "file:./aas-dev-stack-0.1.1.tgz",
|
|
56
|
+
"@effect/vitest": "4.0.6",
|
|
57
|
+
"@types/node": "22.20.5",
|
|
58
|
+
"effect": "4.0.6"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Init, the second install, verify
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pnpm exec aas init
|
|
67
|
+
pnpm exec aas verify
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`aas init` writes the managed files and adds `"verify": "aas verify"` when that script is missing. When `pnpm-lock.yaml` already exists, init deletes `node_modules/.pnpm-workspace-state-v1.json` and runs `pnpm install`, so the lockfile overrides match the template. A second `pnpm install` that only prints "Already up to date" leaves those overrides stale, and the next `pnpm exec` fails with `ERR_PNPM_VERIFY_DEPS_BEFORE_RUN`.
|
|
71
|
+
|
|
72
|
+
If `pnpm exec` is already failing with that error, run `pnpm install`. When the workspace file changed and install does not refresh, run `node node_modules/@aas-dev/stack/bin/aas.js init`.
|
|
73
|
+
|
|
74
|
+
When `core.hooksPath` is already set, init prints the path and skips `vp hooks enable`. It does not take that path.
|
|
75
|
+
|
|
76
|
+
`aas verify` prints nothing when it passes. A failure is `file:line: reason`.
|
|
77
|
+
|
|
78
|
+
`aas explain` lists every ban and the approved alternative. `aas explain no-raw-io` is one rule. `aas doctor` checks the Node and pnpm pins, the hooks path, lockfile overrides, the patched `tsc`, and drift. It exits non-zero when a check fails.
|
|
79
|
+
|
|
80
|
+
## Managed files
|
|
81
|
+
|
|
82
|
+
| File | Role |
|
|
83
|
+
| ------------------------------ | --------------------------------------------------------------------- |
|
|
84
|
+
| `.cursor/hooks.json` | Cursor `beforeShellExecution` hook, `failClosed` |
|
|
85
|
+
| `.vite-hooks/pre-commit` | Runs `pnpm exec aas verify` |
|
|
86
|
+
| `pnpm-workspace.yaml` | Pins, overrides, peer rules, `allowBuilds`. Extra build keys are kept |
|
|
87
|
+
| `AGENTS.md` | The patterns the rules enforce |
|
|
88
|
+
| `.github/workflows/verify.yml` | CI runs `pnpm exec aas verify` |
|
|
89
|
+
| `vite.config.ts` | `defineConfig()` from `@aas-dev/stack/config` |
|
|
90
|
+
| `tsconfig.json` | Extends `@aas-dev/stack/tsconfig.base.json` |
|
|
91
|
+
|
|
92
|
+
`tsconfig.base.json` sets `noEmit`. `aas verify` runs `effect-tsgo patch` and then `tsc --noEmit`. That is the typecheck. Library output is `vp pack`, not `tsc`.
|
|
93
|
+
|
|
94
|
+
`aas sync` rewrites the managed files and keeps `allowBuilds` keys that are not in the template.
|
|
95
|
+
|
|
96
|
+
## Patterns the rules keep
|
|
97
|
+
|
|
98
|
+
Errors are a `Schema.TaggedError`. Config is `Config` from `.env.schema`, or `Config.orElse(() => Config.succeed(...))` for a computed fallback such as a homedir path. `process.env` stays banned. Services extend `Context.Service`. IO goes through Effect, except `node:net` and `node:tls`, which are allowed only in a file named `*.adapter.ts`. Tests use `it.effect` from `@effect/vitest`. The full text is the generated `AGENTS.md`. `aas explain` prints the same alternatives.
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Effect, FileSystem } from "effect";
|
|
2
|
+
export declare const doctor: (cwd: string) => Effect.Effect<{
|
|
3
|
+
ok: boolean;
|
|
4
|
+
text: string;
|
|
5
|
+
}, import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { findBin, runTool, succeeded } from "./run.js";
|
|
2
|
+
import { sameScalars, scalarEntries } from "./workspace.js";
|
|
3
|
+
import { driftLines } from "./verify.js";
|
|
4
|
+
import { Effect, FileSystem, Result, Schema } from "effect";
|
|
5
|
+
// src/doctor.ts
|
|
6
|
+
const Engines = Schema.Struct({
|
|
7
|
+
node: Schema.String,
|
|
8
|
+
pnpm: Schema.String
|
|
9
|
+
});
|
|
10
|
+
const Manifest = Schema.Struct({ engines: Engines });
|
|
11
|
+
const StateSettings = Schema.Struct({ overrides: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) });
|
|
12
|
+
const WorkspaceState = Schema.Struct({ settings: Schema.optionalKey(StateSettings) });
|
|
13
|
+
const decodeManifest = Schema.decodeResult(Schema.fromJsonString(Manifest));
|
|
14
|
+
const decodeState = Schema.decodeResult(Schema.fromJsonString(WorkspaceState));
|
|
15
|
+
const nodeEngine = /^>=(\d+)\.(\d+)\.(\d+)$/u;
|
|
16
|
+
const exactVersion = /^v?(\d+)\.(\d+)\.(\d+)$/u;
|
|
17
|
+
const versionOf = (text) => {
|
|
18
|
+
const matched = exactVersion.exec(text.trim());
|
|
19
|
+
const major = matched?.[1];
|
|
20
|
+
const minor = matched?.[2];
|
|
21
|
+
const patch = matched?.[3];
|
|
22
|
+
if (major === void 0 || minor === void 0 || patch === void 0) return;
|
|
23
|
+
return {
|
|
24
|
+
major: Number(major),
|
|
25
|
+
minor: Number(minor),
|
|
26
|
+
patch: Number(patch)
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
const atLeast = (actual, required) => {
|
|
30
|
+
if (actual.major !== required.major) return actual.major > required.major;
|
|
31
|
+
if (actual.minor !== required.minor) return actual.minor > required.minor;
|
|
32
|
+
return actual.patch >= required.patch;
|
|
33
|
+
};
|
|
34
|
+
const pair = (key, value) => [key, value];
|
|
35
|
+
const recordPairs = (record) => {
|
|
36
|
+
const entries = [];
|
|
37
|
+
for (const [key, value] of Object.entries(record)) entries.push(pair(key, value));
|
|
38
|
+
return entries;
|
|
39
|
+
};
|
|
40
|
+
const typescriptPackage = (start) => Effect.gen(function* () {
|
|
41
|
+
const fs = yield* FileSystem.FileSystem;
|
|
42
|
+
const segments = start.split("/");
|
|
43
|
+
let index = segments.length;
|
|
44
|
+
while (index > 1) {
|
|
45
|
+
index -= 1;
|
|
46
|
+
const candidate = `${segments.slice(0, index).join("/")}/node_modules/typescript`;
|
|
47
|
+
if (yield* fs.exists(candidate)) return candidate;
|
|
48
|
+
}
|
|
49
|
+
return "";
|
|
50
|
+
});
|
|
51
|
+
const nativeBinary = (typescriptDir) => Effect.gen(function* () {
|
|
52
|
+
const fs = yield* FileSystem.FileSystem;
|
|
53
|
+
const name = `typescript-${process.platform}-${process.arch}`;
|
|
54
|
+
const binaryName = process.platform === "win32" ? "tsc.exe" : "tsc";
|
|
55
|
+
const parent = typescriptDir.split("/").slice(0, -1).join("/");
|
|
56
|
+
const links = [`${typescriptDir}/node_modules/@typescript/${name}`, `${parent}/@typescript/${name}`];
|
|
57
|
+
for (const link of links) if (yield* fs.exists(link)) return `${yield* fs.realPath(link)}/lib/${binaryName}`;
|
|
58
|
+
return "";
|
|
59
|
+
});
|
|
60
|
+
const patchedBinary = (tscBin) => Effect.gen(function* () {
|
|
61
|
+
const fs = yield* FileSystem.FileSystem;
|
|
62
|
+
const at = tscBin.lastIndexOf("/node_modules/.bin/");
|
|
63
|
+
if (at <= 0) return "";
|
|
64
|
+
const linked = yield* typescriptPackage(tscBin.slice(0, at));
|
|
65
|
+
if (linked === "") return "";
|
|
66
|
+
return yield* nativeBinary(yield* fs.realPath(linked));
|
|
67
|
+
});
|
|
68
|
+
const enginesCheck = () => Effect.gen(function* () {
|
|
69
|
+
const text = yield* (yield* FileSystem.FileSystem).readFileString(`${import.meta.dirname}/../package.json`);
|
|
70
|
+
const decoded = decodeManifest(text);
|
|
71
|
+
if (Result.isFailure(decoded)) return ["package.json:1: engines are missing"];
|
|
72
|
+
const nodeRequired = nodeEngine.exec(decoded.success.engines.node);
|
|
73
|
+
const nodeMajor = nodeRequired?.[1];
|
|
74
|
+
const nodeMinor = nodeRequired?.[2];
|
|
75
|
+
const nodePatch = nodeRequired?.[3];
|
|
76
|
+
const nodeActual = versionOf(process.version);
|
|
77
|
+
const lines = [];
|
|
78
|
+
if (nodeMajor === void 0 || nodeMinor === void 0 || nodePatch === void 0 || nodeActual === void 0 || !atLeast(nodeActual, {
|
|
79
|
+
major: Number(nodeMajor),
|
|
80
|
+
minor: Number(nodeMinor),
|
|
81
|
+
patch: Number(nodePatch)
|
|
82
|
+
})) lines.push(`node:1: ${process.version} does not satisfy ${decoded.success.engines.node}; install Node ${decoded.success.engines.node}`);
|
|
83
|
+
else lines.push(`node: ${process.version} matches ${decoded.success.engines.node}`);
|
|
84
|
+
const pnpm = yield* runTool({
|
|
85
|
+
bin: "pnpm",
|
|
86
|
+
args: ["--version"],
|
|
87
|
+
cwd: import.meta.dirname
|
|
88
|
+
});
|
|
89
|
+
const pnpmActual = succeeded(pnpm.exitCode) ? versionOf(pnpm.stdout) : void 0;
|
|
90
|
+
const pnpmRequired = versionOf(decoded.success.engines.pnpm);
|
|
91
|
+
if (pnpmActual === void 0 || pnpmRequired === void 0 || pnpmActual.major !== pnpmRequired.major || pnpmActual.minor !== pnpmRequired.minor || pnpmActual.patch !== pnpmRequired.patch) lines.push(`pnpm:1: ${pnpm.stdout.trim() || "missing"} does not match ${decoded.success.engines.pnpm}; install pnpm ${decoded.success.engines.pnpm}`);
|
|
92
|
+
else lines.push(`pnpm: ${pnpm.stdout.trim()} matches ${decoded.success.engines.pnpm}`);
|
|
93
|
+
return lines;
|
|
94
|
+
});
|
|
95
|
+
const hooksCheck = (cwd) => Effect.gen(function* () {
|
|
96
|
+
const git = yield* runTool({
|
|
97
|
+
bin: "git",
|
|
98
|
+
args: ["rev-parse", "--git-dir"],
|
|
99
|
+
cwd
|
|
100
|
+
});
|
|
101
|
+
if (!succeeded(git.exitCode)) return ["hooks: not a git repository; vp hooks enable skipped"];
|
|
102
|
+
const hooksPath = yield* runTool({
|
|
103
|
+
bin: "git",
|
|
104
|
+
args: [
|
|
105
|
+
"config",
|
|
106
|
+
"--get",
|
|
107
|
+
"core.hooksPath"
|
|
108
|
+
],
|
|
109
|
+
cwd
|
|
110
|
+
});
|
|
111
|
+
if (!succeeded(hooksPath.exitCode)) return ["git:1: core.hooksPath is unset; run pnpm exec vp hooks enable"];
|
|
112
|
+
const path = hooksPath.stdout.trim();
|
|
113
|
+
if (path === ".vite-hooks" || path === `${cwd}/.vite-hooks` || path.endsWith("/.vite-hooks")) return [`hooks: core.hooksPath is ${path}`];
|
|
114
|
+
return [`hooks: core.hooksPath is ${path}; vp hooks enable skipped`];
|
|
115
|
+
});
|
|
116
|
+
const lockCheck = (cwd) => Effect.gen(function* () {
|
|
117
|
+
const fs = yield* FileSystem.FileSystem;
|
|
118
|
+
const workspacePath = `${cwd}/pnpm-workspace.yaml`;
|
|
119
|
+
if (!(yield* fs.exists(workspacePath))) return ["pnpm-workspace.yaml:1: missing; run aas init"];
|
|
120
|
+
const workspace = yield* fs.readFileString(workspacePath);
|
|
121
|
+
const expected = scalarEntries(workspace, "overrides");
|
|
122
|
+
if (expected === void 0) return ["pnpm-workspace.yaml:1: overrides are missing; run aas sync"];
|
|
123
|
+
if (!(yield* fs.exists(`${cwd}/pnpm-lock.yaml`))) return ["pnpm-lock.yaml:1: missing lockfile; run pnpm install"];
|
|
124
|
+
const lock = yield* fs.readFileString(`${cwd}/pnpm-lock.yaml`);
|
|
125
|
+
const locked = scalarEntries(lock, "overrides");
|
|
126
|
+
if (locked === void 0 || !sameScalars(expected, locked)) return ["pnpm-lock.yaml:1: overrides are stale; run pnpm install"];
|
|
127
|
+
if (!(yield* fs.exists(`${cwd}/node_modules`))) return ["node_modules:1: missing; run pnpm install"];
|
|
128
|
+
const statePath = `${cwd}/node_modules/.pnpm-workspace-state-v1.json`;
|
|
129
|
+
if (!(yield* fs.exists(statePath))) return ["node_modules/.pnpm-workspace-state-v1.json:1: missing install state; run pnpm install"];
|
|
130
|
+
const stateText = yield* fs.readFileString(statePath);
|
|
131
|
+
const state = decodeState(stateText);
|
|
132
|
+
if (Result.isFailure(state)) return ["node_modules/.pnpm-workspace-state-v1.json:1: unreadable install state; run pnpm install"];
|
|
133
|
+
const overrides = state.success.settings?.overrides;
|
|
134
|
+
const installed = overrides === void 0 ? [] : recordPairs(overrides);
|
|
135
|
+
if (!sameScalars(expected, installed)) return ["node_modules/.pnpm-workspace-state-v1.json:1: overrides are stale; run pnpm install"];
|
|
136
|
+
return ["lockfile: overrides match pnpm-workspace.yaml"];
|
|
137
|
+
});
|
|
138
|
+
const tscCheck = () => Effect.gen(function* () {
|
|
139
|
+
const fs = yield* FileSystem.FileSystem;
|
|
140
|
+
const tsc = yield* findBin("tsc").pipe(Effect.catchTag("CommandFailed", () => Effect.succeed("")));
|
|
141
|
+
if (tsc === "") return ["tsc:1: missing executable tsc"];
|
|
142
|
+
const binary = yield* patchedBinary(tsc);
|
|
143
|
+
if (binary === "" || !(yield* fs.exists(`${binary}.original`))) return ["tsc:1: TypeScript is not patched with @effect/tsgo; run pnpm exec effect-tsgo patch"];
|
|
144
|
+
return ["tsc: patched"];
|
|
145
|
+
});
|
|
146
|
+
const failed = (line) => /^\S+:\d+:/u.test(line);
|
|
147
|
+
const doctor = (cwd) => Effect.gen(function* () {
|
|
148
|
+
const lines = [
|
|
149
|
+
...yield* enginesCheck(),
|
|
150
|
+
...yield* hooksCheck(cwd),
|
|
151
|
+
...yield* lockCheck(cwd),
|
|
152
|
+
...yield* tscCheck()
|
|
153
|
+
];
|
|
154
|
+
const drift = yield* driftLines(cwd);
|
|
155
|
+
if (drift.length === 0) lines.push("drift: managed files match the templates");
|
|
156
|
+
else lines.push(...drift);
|
|
157
|
+
return {
|
|
158
|
+
ok: lines.every((line) => !failed(line)),
|
|
159
|
+
text: lines.join("\n")
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
export { doctor };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { CommandFailed } from "./errors.js";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
export declare const explanation: (names: ReadonlyArray<string>, rule: string | undefined) => string;
|
|
4
|
+
export declare const explain: (rule: string | undefined) => Effect.Effect<undefined, CommandFailed | import("effect/PlatformError").PlatformError, import("effect/Stdio").Stdio>;
|
package/dist/explain.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CommandFailed } from "./errors.js";
|
|
2
|
+
import { writeStdout } from "./run.js";
|
|
3
|
+
import { ruleNames } from "./plugin/aas.js";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
// src/explain.ts
|
|
6
|
+
const texts = {
|
|
7
|
+
"effect-tests": "Imports from vitest and focused, skipped, or todo tests hide failures from the gate. Import assert, describe, and it from @effect/vitest and run it.effect.",
|
|
8
|
+
"no-bypass-comment": "A directive comment can silence the diagnostic it sits on. Delete @ts-ignore, @ts-expect-error, @ts-nocheck, eslint-disable, eslint-enable, oxlint-disable, and oxlint-enable. Fix the code.",
|
|
9
|
+
"no-comments": "Comments rot next to the code they describe. Use a clearer name, or write the explanation in AGENTS.md, an ADR, or the README. A shebang is not a comment.",
|
|
10
|
+
"no-effect-run": "Effect.runPromise, Effect.runSync, Effect.runFork, and Effect.runPromiseExit leave the Effect channel. Stay inside Effect. Tests use it.effect. NodeRuntime.runMain is the process boundary.",
|
|
11
|
+
"no-error-class": "new Error and the other built-in error constructors are untyped failures. Define a Schema.TaggedError and pass the brand parameter.",
|
|
12
|
+
"no-explicit-any": "any turns the checker off. Keep the value unknown until a Schema decodes it.",
|
|
13
|
+
"no-module-let": "Module-scope let and var are shared mutable state. Keep that state on a Context.Service. A binding inside a function is fine.",
|
|
14
|
+
"no-non-null-assertion": "A non-null assertion hides a missing value. Narrow the value, or decode it with Schema.",
|
|
15
|
+
"no-plain-class": "A plain class is a second object style. Extend Schema.TaggedError or Context.Service.",
|
|
16
|
+
"no-process-env": "process.env, process[\"env\"], and process?.env skip the config boundary. Declare a supplied value in .env.schema and read it with Config. A computed fallback, such as a homedir path, is Config.String(key).pipe(Config.orElse(() => Config.succeed(value))). That fallback is not Config.withDefault, and the key does not have to be in .env.schema. process.env stays banned.",
|
|
17
|
+
"no-raw-io": "Node fs, http, https, child_process, global fetch, and console bypass Effect's services. Use FileSystem, HttpClient, ChildProcess, and Console. node:net, net, node:tls, and tls have no Effect client in Effect 4.0.6, so those four specifiers are allowed only in a file whose name ends with .adapter.ts. That file is the Node TCP and TLS entry. The same file still bans every other raw import. No command grants an exception.",
|
|
18
|
+
"no-throw": "throw leaves the Effect channel. Yield a Schema.TaggedError.",
|
|
19
|
+
"no-try": "try and catch leave the Effect channel. Failures stay on the Effect error channel.",
|
|
20
|
+
"no-type-assertion": "A type assertion, including as const and an angle-bracket assertion, invents a type. Parse with Schema, or keep a tuple with a const type parameter."
|
|
21
|
+
};
|
|
22
|
+
const formatRule = (name) => {
|
|
23
|
+
const text = texts[name];
|
|
24
|
+
if (text === void 0) return "";
|
|
25
|
+
return `aas/${name}\n${text}`;
|
|
26
|
+
};
|
|
27
|
+
const explanation = (names, rule) => {
|
|
28
|
+
if (rule === void 0 || rule === "") {
|
|
29
|
+
const blocks = [];
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
const block = formatRule(name);
|
|
32
|
+
if (block === "") return "";
|
|
33
|
+
blocks.push(block);
|
|
34
|
+
}
|
|
35
|
+
return blocks.join("\n\n");
|
|
36
|
+
}
|
|
37
|
+
const name = rule.startsWith("aas/") ? rule.slice(4) : rule;
|
|
38
|
+
return formatRule(name);
|
|
39
|
+
};
|
|
40
|
+
const explain = (rule) => Effect.gen(function* () {
|
|
41
|
+
const text = explanation(ruleNames, rule);
|
|
42
|
+
if (text === "") {
|
|
43
|
+
const line = rule === void 0 || rule === "" ? "explain:1: missing text for a rule" : `explain:1: unknown rule ${rule}`;
|
|
44
|
+
return yield* new CommandFailed({ lines: [line] });
|
|
45
|
+
}
|
|
46
|
+
yield* writeStdout(text);
|
|
47
|
+
});
|
|
48
|
+
export { explain, explanation };
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { CommandFailed } from "./errors.js";
|
|
2
|
+
import { Effect, FileSystem } from "effect";
|
|
3
|
+
export declare const ensureVerifyScript: (cwd: string) => Effect.Effect<undefined, CommandFailed | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CommandFailed } from "./errors.js";
|
|
2
|
+
import { findBin, runTool, succeeded } from "./run.js";
|
|
3
|
+
import { Effect, FileSystem, Result, Schema } from "effect";
|
|
4
|
+
// src/package-script.ts
|
|
5
|
+
const Manifest = Schema.Record(Schema.String, Schema.Unknown);
|
|
6
|
+
const Scripts = Schema.Record(Schema.String, Schema.String);
|
|
7
|
+
const decodeManifest = Schema.decodeResult(Schema.fromJsonString(Manifest));
|
|
8
|
+
const decodeScripts = Schema.decodeUnknownResult(Scripts);
|
|
9
|
+
const verifyScript = "aas verify";
|
|
10
|
+
const readScripts = (value) => Effect.gen(function* () {
|
|
11
|
+
if (value === void 0) return {};
|
|
12
|
+
const parsed = decodeScripts(value);
|
|
13
|
+
if (Result.isFailure(parsed)) return yield* new CommandFailed({ lines: ["package.json:1: scripts must be a map of strings"] });
|
|
14
|
+
return parsed.success;
|
|
15
|
+
});
|
|
16
|
+
const ensureVerifyScript = (cwd) => Effect.gen(function* () {
|
|
17
|
+
const fs = yield* FileSystem.FileSystem;
|
|
18
|
+
const path = `${cwd}/package.json`;
|
|
19
|
+
if (!(yield* fs.exists(path))) return;
|
|
20
|
+
const text = yield* fs.readFileString(path);
|
|
21
|
+
const decoded = decodeManifest(text);
|
|
22
|
+
if (Result.isFailure(decoded)) return yield* new CommandFailed({ lines: ["package.json:1: package.json is not an object"] });
|
|
23
|
+
const manifest = decoded.success;
|
|
24
|
+
const scripts = yield* readScripts(manifest["scripts"]);
|
|
25
|
+
if (scripts["verify"] !== void 0) return;
|
|
26
|
+
const next = {
|
|
27
|
+
...manifest,
|
|
28
|
+
scripts: {
|
|
29
|
+
...scripts,
|
|
30
|
+
verify: verifyScript
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
yield* fs.writeFileString(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
34
|
+
const vp = yield* findBin("vp");
|
|
35
|
+
const formatted = yield* runTool({
|
|
36
|
+
bin: vp,
|
|
37
|
+
args: ["fmt", "package.json"],
|
|
38
|
+
cwd
|
|
39
|
+
});
|
|
40
|
+
if (!succeeded(formatted.exitCode)) return yield* new CommandFailed({ lines: ["package.json:1: format"] });
|
|
41
|
+
});
|
|
42
|
+
export { ensureVerifyScript };
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
// plugin/aas.mjs
|
|
2
|
+
const markers = [
|
|
3
|
+
"@ts-ignore",
|
|
4
|
+
"@ts-expect-error",
|
|
5
|
+
"@ts-nocheck",
|
|
6
|
+
"eslint-disable",
|
|
7
|
+
"eslint-enable",
|
|
8
|
+
"oxlint-disable",
|
|
9
|
+
"oxlint-enable"
|
|
10
|
+
];
|
|
11
|
+
const errorNames = /* @__PURE__ */ new Set([
|
|
12
|
+
"Error",
|
|
13
|
+
"AggregateError",
|
|
14
|
+
"EvalError",
|
|
15
|
+
"RangeError",
|
|
16
|
+
"ReferenceError",
|
|
17
|
+
"SyntaxError",
|
|
18
|
+
"TypeError",
|
|
19
|
+
"URIError"
|
|
20
|
+
]);
|
|
21
|
+
const rawSpecifiers = /* @__PURE__ */ new Set([
|
|
22
|
+
"child_process",
|
|
23
|
+
"fs",
|
|
24
|
+
"http",
|
|
25
|
+
"https",
|
|
26
|
+
"node:child_process",
|
|
27
|
+
"node:fs",
|
|
28
|
+
"node:fs/promises",
|
|
29
|
+
"node:http",
|
|
30
|
+
"node:https"
|
|
31
|
+
]);
|
|
32
|
+
const socketSpecifiers = /* @__PURE__ */ new Set([
|
|
33
|
+
"net",
|
|
34
|
+
"node:net",
|
|
35
|
+
"node:tls",
|
|
36
|
+
"tls"
|
|
37
|
+
]);
|
|
38
|
+
const consoleMethods = /* @__PURE__ */ new Set([
|
|
39
|
+
"debug",
|
|
40
|
+
"error",
|
|
41
|
+
"info",
|
|
42
|
+
"log",
|
|
43
|
+
"warn"
|
|
44
|
+
]);
|
|
45
|
+
const effectRunMethods = /* @__PURE__ */ new Set([
|
|
46
|
+
"runFork",
|
|
47
|
+
"runPromise",
|
|
48
|
+
"runPromiseExit",
|
|
49
|
+
"runSync"
|
|
50
|
+
]);
|
|
51
|
+
const bannedTestMembers = /* @__PURE__ */ new Set([
|
|
52
|
+
"only",
|
|
53
|
+
"skip",
|
|
54
|
+
"skipIf",
|
|
55
|
+
"todo"
|
|
56
|
+
]);
|
|
57
|
+
const testAliases = /* @__PURE__ */ new Set([
|
|
58
|
+
"fdescribe",
|
|
59
|
+
"fit",
|
|
60
|
+
"ftest",
|
|
61
|
+
"xdescribe",
|
|
62
|
+
"xit",
|
|
63
|
+
"xtest"
|
|
64
|
+
]);
|
|
65
|
+
const report = (context, node, message) => {
|
|
66
|
+
context.report({
|
|
67
|
+
node,
|
|
68
|
+
message
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const literalName = (property, computed) => {
|
|
72
|
+
if (!computed && property.type === "Identifier") return property.name;
|
|
73
|
+
if (computed && (property.type === "Literal" || property.type === "StringLiteral") && typeof property.value === "string") return property.value;
|
|
74
|
+
return "";
|
|
75
|
+
};
|
|
76
|
+
const isProcessEnv = (node) => {
|
|
77
|
+
if (node.type !== "MemberExpression") return false;
|
|
78
|
+
return node.object.type === "Identifier" && node.object.name === "process" && literalName(node.property, node.computed) === "env";
|
|
79
|
+
};
|
|
80
|
+
const hasName = (node, name) => {
|
|
81
|
+
if (node === null || node === void 0 || typeof node !== "object") return false;
|
|
82
|
+
if (node.type === "Identifier" && node.name === name) return true;
|
|
83
|
+
return hasName(node.callee, name) || hasName(node.object, name) || hasName(node.property, name) || hasName(node.expression, name);
|
|
84
|
+
};
|
|
85
|
+
const blessedSuper = (node) => hasName(node, "TaggedError") || hasName(node, "Service");
|
|
86
|
+
const specifier = (node) => {
|
|
87
|
+
if (node === null || node === void 0) return "";
|
|
88
|
+
if ((node.type === "Literal" || node.type === "StringLiteral") && typeof node.value === "string") return node.value;
|
|
89
|
+
return "";
|
|
90
|
+
};
|
|
91
|
+
const chainParts = (callee) => {
|
|
92
|
+
const parts = [];
|
|
93
|
+
let current = callee;
|
|
94
|
+
while (current.type === "MemberExpression" || current.type === "ChainExpression") {
|
|
95
|
+
if (current.type === "ChainExpression") {
|
|
96
|
+
current = current.expression;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (current.computed || current.property.type !== "Identifier") return [];
|
|
100
|
+
parts.push(current.property.name);
|
|
101
|
+
current = current.object;
|
|
102
|
+
}
|
|
103
|
+
if (current.type !== "Identifier") return [];
|
|
104
|
+
parts.push(current.name);
|
|
105
|
+
parts.reverse();
|
|
106
|
+
return parts;
|
|
107
|
+
};
|
|
108
|
+
const isItEffect = (parts) => {
|
|
109
|
+
if (parts[0] !== "it" || parts[1] !== "effect") return false;
|
|
110
|
+
let index = 2;
|
|
111
|
+
while (index < parts.length) {
|
|
112
|
+
const part = parts[index];
|
|
113
|
+
if (part !== void 0 && bannedTestMembers.has(part)) return false;
|
|
114
|
+
index += 1;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
118
|
+
const flagModuleBinding = (context, statement) => {
|
|
119
|
+
if (statement === null || statement === void 0) return;
|
|
120
|
+
if (statement.type === "VariableDeclaration" && statement.kind !== "const") report(context, statement, "module-scope let and var are banned; keep state in a Context.Service");
|
|
121
|
+
const binding = statement.init ?? statement.left;
|
|
122
|
+
if ((statement.type === "ForStatement" || statement.type === "ForInStatement" || statement.type === "ForOfStatement") && binding !== null && binding !== void 0 && binding.type === "VariableDeclaration" && binding.kind !== "const") report(context, binding, "module-scope let and var are banned; keep state in a Context.Service");
|
|
123
|
+
};
|
|
124
|
+
const noThrow = {
|
|
125
|
+
meta: { type: "problem" },
|
|
126
|
+
create(context) {
|
|
127
|
+
return { ThrowStatement(node) {
|
|
128
|
+
report(context, node, "throw is banned; yield a Schema.TaggedError");
|
|
129
|
+
} };
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const noTry = {
|
|
133
|
+
meta: { type: "problem" },
|
|
134
|
+
create(context) {
|
|
135
|
+
return { TryStatement(node) {
|
|
136
|
+
report(context, node, "try is banned; failures stay on the Effect channel");
|
|
137
|
+
} };
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
const noErrorClass = {
|
|
141
|
+
meta: { type: "problem" },
|
|
142
|
+
create(context) {
|
|
143
|
+
return { NewExpression(node) {
|
|
144
|
+
const callee = node.callee;
|
|
145
|
+
const name = callee.type === "Identifier" ? callee.name : callee.type === "MemberExpression" ? literalName(callee.property, callee.computed) : "";
|
|
146
|
+
if (errorNames.has(name)) report(context, node, "new Error is banned; define a Schema.TaggedError");
|
|
147
|
+
} };
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
const noProcessEnv = {
|
|
151
|
+
meta: { type: "problem" },
|
|
152
|
+
create(context) {
|
|
153
|
+
return { MemberExpression(node) {
|
|
154
|
+
if (isProcessEnv(node)) report(context, node, "process.env is banned; declare the variable in .env.schema and read it with Config");
|
|
155
|
+
} };
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const noTypeAssertion = {
|
|
159
|
+
meta: { type: "problem" },
|
|
160
|
+
create(context) {
|
|
161
|
+
return {
|
|
162
|
+
TSAsExpression(node) {
|
|
163
|
+
report(context, node, "type assertions are banned, including as const; parse with Schema or use a const type parameter");
|
|
164
|
+
},
|
|
165
|
+
TSTypeAssertion(node) {
|
|
166
|
+
report(context, node, "angle-bracket assertions are banned; parse with Schema or use a const type parameter");
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
const noExplicitAny = {
|
|
172
|
+
meta: { type: "problem" },
|
|
173
|
+
create(context) {
|
|
174
|
+
return { TSAnyKeyword(node) {
|
|
175
|
+
report(context, node, "any is banned; use unknown and decode it with Schema");
|
|
176
|
+
} };
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const noNonNullAssertion = {
|
|
180
|
+
meta: { type: "problem" },
|
|
181
|
+
create(context) {
|
|
182
|
+
return { TSNonNullExpression(node) {
|
|
183
|
+
report(context, node, "non-null assertions are banned; narrow the value or decode it with Schema");
|
|
184
|
+
} };
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
const textOf = (context) => {
|
|
188
|
+
const source = context.sourceCode;
|
|
189
|
+
if (typeof source.text === "string") return source.text;
|
|
190
|
+
if (typeof source.getText === "function") return source.getText();
|
|
191
|
+
return "";
|
|
192
|
+
};
|
|
193
|
+
const commentStart = (comment) => {
|
|
194
|
+
const range = comment.range;
|
|
195
|
+
if (Array.isArray(range)) {
|
|
196
|
+
const start = range[0];
|
|
197
|
+
return typeof start === "number" ? start : -1;
|
|
198
|
+
}
|
|
199
|
+
if (range !== null && range !== void 0 && typeof range.start === "number") return range.start;
|
|
200
|
+
if (typeof comment.start === "number") return comment.start;
|
|
201
|
+
return -1;
|
|
202
|
+
};
|
|
203
|
+
const isShebang = (comment, text) => {
|
|
204
|
+
if (comment.type === "Shebang" || comment.type === "Hashbang") return true;
|
|
205
|
+
return text.startsWith("#!") && commentStart(comment) === 0;
|
|
206
|
+
};
|
|
207
|
+
const noComments = {
|
|
208
|
+
meta: { type: "problem" },
|
|
209
|
+
create(context) {
|
|
210
|
+
const text = textOf(context);
|
|
211
|
+
return { Program(node) {
|
|
212
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
213
|
+
if (isShebang(comment, text)) continue;
|
|
214
|
+
const message = "comments are banned; use clearer names or docs";
|
|
215
|
+
if (comment.loc === void 0 || comment.loc === null) report(context, node, message);
|
|
216
|
+
else context.report({
|
|
217
|
+
loc: comment.loc,
|
|
218
|
+
message
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} };
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
const noBypassComment = {
|
|
225
|
+
meta: { type: "problem" },
|
|
226
|
+
create(context) {
|
|
227
|
+
return { Program(node) {
|
|
228
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
229
|
+
let matched = "";
|
|
230
|
+
for (const marker of markers) if (comment.value.includes(marker)) {
|
|
231
|
+
matched = marker;
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
if (matched === "") continue;
|
|
235
|
+
const message = `directive comments are banned (${matched})`;
|
|
236
|
+
if (comment.loc === void 0 || comment.loc === null) report(context, node, message);
|
|
237
|
+
else context.report({
|
|
238
|
+
loc: comment.loc,
|
|
239
|
+
message
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
} };
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
const effectTests = {
|
|
246
|
+
meta: { type: "problem" },
|
|
247
|
+
create(context) {
|
|
248
|
+
const filename = typeof context.filename === "string" ? context.filename : "";
|
|
249
|
+
const isTest = filename.endsWith(".test.ts");
|
|
250
|
+
return {
|
|
251
|
+
ImportDeclaration(node) {
|
|
252
|
+
const source = specifier(node.source);
|
|
253
|
+
if (source === "vitest/config") {
|
|
254
|
+
if (!filename.endsWith("/vitest.config.ts") && !filename.endsWith("\\vitest.config.ts")) report(context, node, "import tests from @effect/vitest");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (source === "vitest" || source.startsWith("vitest/") || source.startsWith("@vitest/")) report(context, node, "import tests from @effect/vitest");
|
|
258
|
+
},
|
|
259
|
+
CallExpression(node) {
|
|
260
|
+
if (!isTest) return;
|
|
261
|
+
const parts = chainParts(node.callee);
|
|
262
|
+
const root = parts[0];
|
|
263
|
+
if (root === void 0) {
|
|
264
|
+
if (node.callee.type === "Identifier" && (node.callee.name === "it" || node.callee.name === "test" || testAliases.has(node.callee.name))) report(context, node, "tests use it.effect from @effect/vitest");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (isItEffect(parts)) return;
|
|
268
|
+
if (root === "it" || root === "test" || testAliases.has(root)) {
|
|
269
|
+
report(context, node, "tests use it.effect from @effect/vitest");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (root === "describe") {
|
|
273
|
+
let index = 1;
|
|
274
|
+
while (index < parts.length) {
|
|
275
|
+
const part = parts[index];
|
|
276
|
+
if (part !== void 0 && bannedTestMembers.has(part)) {
|
|
277
|
+
report(context, node, "skipped, focused, and todo tests are banned");
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
index += 1;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const isAdapterFile = (context) => {
|
|
288
|
+
return (typeof context.filename === "string" ? context.filename : "").endsWith(".adapter.ts");
|
|
289
|
+
};
|
|
290
|
+
const rawMessage = (source) => {
|
|
291
|
+
if (socketSpecifiers.has(source)) return "node:net and node:tls belong in a .adapter.ts file; other raw IO stays banned";
|
|
292
|
+
return "raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess";
|
|
293
|
+
};
|
|
294
|
+
const bannedRaw = (context, node, source) => {
|
|
295
|
+
if (socketSpecifiers.has(source)) {
|
|
296
|
+
if (isAdapterFile(context)) return;
|
|
297
|
+
report(context, node, rawMessage(source));
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (rawSpecifiers.has(source)) report(context, node, rawMessage(source));
|
|
301
|
+
};
|
|
302
|
+
const ruleNames = Object.keys({
|
|
303
|
+
"effect-tests": effectTests,
|
|
304
|
+
"no-bypass-comment": noBypassComment,
|
|
305
|
+
"no-comments": noComments,
|
|
306
|
+
"no-effect-run": {
|
|
307
|
+
meta: { type: "problem" },
|
|
308
|
+
create(context) {
|
|
309
|
+
return { CallExpression(node) {
|
|
310
|
+
const callee = node.callee;
|
|
311
|
+
if (callee.type !== "MemberExpression") return;
|
|
312
|
+
if (callee.object.type === "Identifier" && callee.object.name === "Effect" && effectRunMethods.has(literalName(callee.property, callee.computed))) report(context, node, "Effect.run* is banned; stay inside Effect and use it.effect in tests");
|
|
313
|
+
} };
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
"no-error-class": noErrorClass,
|
|
317
|
+
"no-explicit-any": noExplicitAny,
|
|
318
|
+
"no-module-let": {
|
|
319
|
+
meta: { type: "problem" },
|
|
320
|
+
create(context) {
|
|
321
|
+
return { Program(node) {
|
|
322
|
+
for (const statement of node.body) {
|
|
323
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
324
|
+
flagModuleBinding(context, declaration);
|
|
325
|
+
}
|
|
326
|
+
} };
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
"no-non-null-assertion": noNonNullAssertion,
|
|
330
|
+
"no-plain-class": {
|
|
331
|
+
meta: { type: "problem" },
|
|
332
|
+
create(context) {
|
|
333
|
+
return {
|
|
334
|
+
ClassDeclaration(node) {
|
|
335
|
+
if (!blessedSuper(node.superClass)) report(context, node, "plain classes are banned; extend Schema.TaggedError or Context.Service");
|
|
336
|
+
},
|
|
337
|
+
ClassExpression(node) {
|
|
338
|
+
if (!blessedSuper(node.superClass)) report(context, node, "plain classes are banned; extend Schema.TaggedError or Context.Service");
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
"no-process-env": noProcessEnv,
|
|
344
|
+
"no-raw-io": {
|
|
345
|
+
meta: { type: "problem" },
|
|
346
|
+
create(context) {
|
|
347
|
+
return {
|
|
348
|
+
ImportDeclaration(node) {
|
|
349
|
+
bannedRaw(context, node, specifier(node.source));
|
|
350
|
+
},
|
|
351
|
+
ImportExpression(node) {
|
|
352
|
+
bannedRaw(context, node, specifier(node.source));
|
|
353
|
+
},
|
|
354
|
+
CallExpression(node) {
|
|
355
|
+
const callee = node.callee;
|
|
356
|
+
if (callee.type === "Identifier" && callee.name === "fetch") {
|
|
357
|
+
report(context, node, "global fetch is banned; use Effect HttpClient");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (callee.type === "Identifier" && callee.name === "require") {
|
|
361
|
+
bannedRaw(context, node, specifier(node.arguments[0]));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (callee.type !== "MemberExpression") return;
|
|
365
|
+
const name = literalName(callee.property, callee.computed);
|
|
366
|
+
if (callee.object.type === "Identifier" && callee.object.name === "console" && consoleMethods.has(name)) {
|
|
367
|
+
report(context, node, "console is banned; use Effect Console");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (callee.object.type === "Identifier" && (callee.object.name === "globalThis" || callee.object.name === "global" || callee.object.name === "window") && name === "fetch") report(context, node, "global fetch is banned; use Effect HttpClient");
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
"no-throw": noThrow,
|
|
376
|
+
"no-try": noTry,
|
|
377
|
+
"no-type-assertion": noTypeAssertion
|
|
378
|
+
});
|
|
379
|
+
export { ruleNames };
|
package/dist/program.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { CommandFailed } from "./errors.js";
|
|
2
2
|
import { writeStdout } from "./run.js";
|
|
3
|
-
import { init, sync } from "./scaffold.js";
|
|
4
3
|
import { verify } from "./verify.js";
|
|
4
|
+
import { doctor } from "./doctor.js";
|
|
5
|
+
import { explain } from "./explain.js";
|
|
6
|
+
import { init, sync } from "./scaffold.js";
|
|
5
7
|
import { Cause, Effect } from "effect";
|
|
6
8
|
import { Stdio } from "effect/Stdio";
|
|
7
9
|
// src/program.ts
|
|
8
|
-
const usage = "usage: aas verify | aas init | aas sync";
|
|
10
|
+
const usage = "usage: aas verify | aas init | aas sync | aas explain [rule] | aas doctor";
|
|
9
11
|
const main = (cwd) => Effect.gen(function* () {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
+
const args = yield* (yield* Stdio).args;
|
|
13
|
+
const command = args[0];
|
|
14
|
+
const rule = args[1];
|
|
15
|
+
return yield* dispatch(command, rule, cwd).pipe(Effect.catchTag("CommandFailed", (error) => Effect.gen(function* () {
|
|
12
16
|
yield* writeStdout(error.lines.join("\n"));
|
|
13
17
|
return 1;
|
|
14
18
|
})), Effect.catchCause((cause) => Effect.gen(function* () {
|
|
@@ -16,7 +20,7 @@ const main = (cwd) => Effect.gen(function* () {
|
|
|
16
20
|
return 1;
|
|
17
21
|
})));
|
|
18
22
|
});
|
|
19
|
-
const dispatch = (command, cwd) => Effect.gen(function* () {
|
|
23
|
+
const dispatch = (command, rule, cwd) => Effect.gen(function* () {
|
|
20
24
|
if (command === "verify") {
|
|
21
25
|
yield* verify(cwd);
|
|
22
26
|
return 0;
|
|
@@ -29,6 +33,16 @@ const dispatch = (command, cwd) => Effect.gen(function* () {
|
|
|
29
33
|
yield* sync(cwd);
|
|
30
34
|
return 0;
|
|
31
35
|
}
|
|
36
|
+
if (command === "explain") {
|
|
37
|
+
yield* explain(rule);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
if (command === "doctor") {
|
|
41
|
+
const report = yield* doctor(cwd);
|
|
42
|
+
yield* writeStdout(report.text);
|
|
43
|
+
if (!report.ok) return yield* new CommandFailed({ lines: [] });
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
32
46
|
yield* writeStdout(usage);
|
|
33
47
|
return yield* new CommandFailed({ lines: [] });
|
|
34
48
|
});
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CommandFailed } from "./errors.js";
|
|
2
2
|
import { Effect, FileSystem } from "effect";
|
|
3
3
|
export declare const writeTemplates: (cwd: string) => Effect.Effect<void, import("effect/PlatformError").PlatformError, FileSystem.FileSystem>;
|
|
4
|
-
export declare const init: (cwd: string) => Effect.Effect<void, CommandFailed | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem>;
|
|
4
|
+
export declare const init: (cwd: string) => Effect.Effect<void, CommandFailed | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | import("effect/Stdio").Stdio>;
|
|
5
5
|
export declare const sync: typeof writeTemplates;
|
package/dist/scaffold.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CommandFailed } from "./errors.js";
|
|
2
|
-
import { findBin, runTool, succeeded } from "./run.js";
|
|
2
|
+
import { failLines, findBin, runTool, succeeded, writeStdout } from "./run.js";
|
|
3
3
|
import { managedFiles, readTemplate } from "./templates.js";
|
|
4
|
+
import { mergeAllowBuilds } from "./workspace.js";
|
|
5
|
+
import { ensureVerifyScript } from "./package-script.js";
|
|
4
6
|
import { Effect, FileSystem } from "effect";
|
|
5
7
|
// src/scaffold.ts
|
|
6
8
|
const parentOf = (path) => {
|
|
@@ -11,10 +13,12 @@ const parentOf = (path) => {
|
|
|
11
13
|
const writeTemplates = (cwd) => Effect.gen(function* () {
|
|
12
14
|
const fs = yield* FileSystem.FileSystem;
|
|
13
15
|
for (const file of managedFiles) {
|
|
14
|
-
const
|
|
16
|
+
const template = yield* readTemplate(file.template);
|
|
15
17
|
const target = `${cwd}/${file.path}`;
|
|
16
18
|
const parent = parentOf(target);
|
|
17
19
|
if (parent !== "") yield* fs.makeDirectory(parent, { recursive: true });
|
|
20
|
+
const previous = (yield* fs.exists(target)) ? yield* fs.readFileString(target) : "";
|
|
21
|
+
const text = file.template === "pnpm-workspace.yaml" && previous !== "" ? mergeAllowBuilds(template, previous) : template;
|
|
18
22
|
yield* fs.writeFileString(target, text);
|
|
19
23
|
}
|
|
20
24
|
});
|
|
@@ -34,7 +38,10 @@ const installHooks = (cwd) => Effect.gen(function* () {
|
|
|
34
38
|
],
|
|
35
39
|
cwd
|
|
36
40
|
});
|
|
37
|
-
if (succeeded(hooksPath.exitCode))
|
|
41
|
+
if (succeeded(hooksPath.exitCode)) {
|
|
42
|
+
yield* writeStdout(`core.hooksPath is ${hooksPath.stdout.trim()}; vp hooks enable skipped`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
38
45
|
const vp = yield* findBin("vp");
|
|
39
46
|
const installed = yield* runTool({
|
|
40
47
|
bin: vp,
|
|
@@ -47,9 +54,23 @@ const failOr = (stdout, stderr) => {
|
|
|
47
54
|
const text = `${stdout}\n${stderr}`.trim();
|
|
48
55
|
return [`vp:1: ${text === "" ? "vp hooks enable failed" : text.split("\n")[0] ?? "failed"}`];
|
|
49
56
|
};
|
|
57
|
+
const refreshLockfile = (cwd) => Effect.gen(function* () {
|
|
58
|
+
const fs = yield* FileSystem.FileSystem;
|
|
59
|
+
if (!(yield* fs.exists(`${cwd}/pnpm-lock.yaml`))) return;
|
|
60
|
+
const state = `${cwd}/node_modules/.pnpm-workspace-state-v1.json`;
|
|
61
|
+
if (yield* fs.exists(state)) yield* fs.remove(state);
|
|
62
|
+
const installed = yield* runTool({
|
|
63
|
+
bin: "pnpm",
|
|
64
|
+
args: ["install"],
|
|
65
|
+
cwd
|
|
66
|
+
});
|
|
67
|
+
if (!succeeded(installed.exitCode)) return yield* new CommandFailed({ lines: failLines("pnpm", `${installed.stdout}\n${installed.stderr}`) });
|
|
68
|
+
});
|
|
50
69
|
const init = (cwd) => Effect.gen(function* () {
|
|
51
70
|
yield* writeTemplates(cwd);
|
|
71
|
+
yield* ensureVerifyScript(cwd);
|
|
52
72
|
yield* installHooks(cwd);
|
|
73
|
+
yield* refreshLockfile(cwd);
|
|
53
74
|
});
|
|
54
75
|
const sync = writeTemplates;
|
|
55
76
|
export { init, sync, writeTemplates };
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import { CommandFailed } from "./errors.js";
|
|
2
2
|
import { Effect, FileSystem } from "effect";
|
|
3
|
+
export declare const driftLines: (cwd: string) => Effect.Effect<string[], import("effect/PlatformError").PlatformError, FileSystem.FileSystem>;
|
|
3
4
|
export declare const verify: (cwd: string) => Effect.Effect<undefined, CommandFailed | import("effect/PlatformError").PlatformError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem>;
|
package/dist/verify.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { CommandFailed } from "./errors.js";
|
|
2
2
|
import { diagnosticsFrom, failLines, findBin, runTool, succeeded } from "./run.js";
|
|
3
3
|
import { managedFiles, readTemplate } from "./templates.js";
|
|
4
|
+
import { workspaceDrift } from "./workspace.js";
|
|
4
5
|
import { Effect, FileSystem, Result, Schema } from "effect";
|
|
5
6
|
// src/verify.ts
|
|
6
|
-
const
|
|
7
|
+
const driftLines = (cwd) => Effect.gen(function* () {
|
|
7
8
|
const fs = yield* FileSystem.FileSystem;
|
|
8
9
|
const lines = [];
|
|
9
10
|
for (const file of managedFiles) {
|
|
@@ -11,10 +12,30 @@ const checkDrift = (cwd) => Effect.gen(function* () {
|
|
|
11
12
|
const target = `${cwd}/${file.path}`;
|
|
12
13
|
const present = yield* fs.exists(target);
|
|
13
14
|
const actual = present ? yield* fs.readFileString(target) : "";
|
|
15
|
+
if (file.template === "pnpm-workspace.yaml") {
|
|
16
|
+
const reason = present ? workspaceDrift(expected, actual) : "drifted from the aas template; run aas sync";
|
|
17
|
+
if (reason !== "") lines.push(`${file.path}:1: ${reason}`);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
14
20
|
if (!present || actual !== expected) lines.push(`${file.path}:1: drifted from the aas template; run aas sync`);
|
|
15
21
|
}
|
|
22
|
+
return lines;
|
|
23
|
+
});
|
|
24
|
+
const checkDrift = (cwd) => Effect.gen(function* () {
|
|
25
|
+
const lines = yield* driftLines(cwd);
|
|
16
26
|
if (lines.length > 0) return yield* new CommandFailed({ lines });
|
|
17
27
|
});
|
|
28
|
+
const patchTypeScript = (tsc) => Effect.gen(function* () {
|
|
29
|
+
const at = tsc.lastIndexOf("/node_modules/.bin/");
|
|
30
|
+
if (at <= 0) return yield* new CommandFailed({ lines: [`tsc:1: unable to locate the typescript package for ${tsc}`] });
|
|
31
|
+
const effectTsgo = yield* findBin("effect-tsgo");
|
|
32
|
+
yield* runChecked({
|
|
33
|
+
bin: effectTsgo,
|
|
34
|
+
args: ["patch", "--typescript"],
|
|
35
|
+
cwd: tsc.slice(0, at),
|
|
36
|
+
label: "effect-tsgo"
|
|
37
|
+
});
|
|
38
|
+
});
|
|
18
39
|
const runChecked = (input) => Effect.gen(function* () {
|
|
19
40
|
const result = yield* runTool(input);
|
|
20
41
|
if (succeeded(result.exitCode)) return;
|
|
@@ -83,18 +104,21 @@ const verify = (cwd) => Effect.gen(function* () {
|
|
|
83
104
|
cwd: target,
|
|
84
105
|
label: "pack"
|
|
85
106
|
});
|
|
86
|
-
if (yield* fs.exists(`${cwd}/tsconfig.json`))
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
if (yield* fs.exists(`${cwd}/tsconfig.json`)) {
|
|
108
|
+
yield* patchTypeScript(tsc);
|
|
109
|
+
yield* runChecked({
|
|
110
|
+
bin: tsc,
|
|
111
|
+
args: [
|
|
112
|
+
"--noEmit",
|
|
113
|
+
"--pretty",
|
|
114
|
+
"false",
|
|
115
|
+
"-p",
|
|
116
|
+
"tsconfig.json"
|
|
117
|
+
],
|
|
118
|
+
cwd,
|
|
119
|
+
label: "tsc"
|
|
120
|
+
});
|
|
121
|
+
}
|
|
98
122
|
const reportPath = `${cwd}/.vitest/aas-report.json`;
|
|
99
123
|
const tested = yield* runTool({
|
|
100
124
|
bin: vp,
|
|
@@ -163,4 +187,4 @@ const testFailures = (text, cwd) => {
|
|
|
163
187
|
}
|
|
164
188
|
return lines;
|
|
165
189
|
};
|
|
166
|
-
export { verify };
|
|
190
|
+
export { driftLines, verify };
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const scalarEntries: (text: string, key: string) => (readonly [string, string])[] | undefined;
|
|
2
|
+
export declare const workspaceDrift: (template: string, actual: string) => "" | "drifted from the aas template; run aas sync";
|
|
3
|
+
export declare const mergeAllowBuilds: (template: string, previous: string) => string;
|
|
4
|
+
export declare const sameScalars: (left: ReadonlyArray<readonly [string, string]>, right: ReadonlyArray<readonly [string, string]>) => boolean;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/workspace.ts
|
|
2
|
+
const unquote = (raw) => {
|
|
3
|
+
const trimmed = raw.trim();
|
|
4
|
+
if (trimmed.length >= 2 && (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1);
|
|
5
|
+
return trimmed;
|
|
6
|
+
};
|
|
7
|
+
const linesOf = (text) => text.split("\n");
|
|
8
|
+
const sectionRange = (lines, key) => {
|
|
9
|
+
const start = lines.findIndex((line) => line === `${key}:`);
|
|
10
|
+
if (start < 0) return;
|
|
11
|
+
let end = start + 1;
|
|
12
|
+
while (end < lines.length) {
|
|
13
|
+
const line = lines[end] ?? "";
|
|
14
|
+
if (line === "" || line.startsWith(" ") || line.startsWith("#")) {
|
|
15
|
+
end += 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
start,
|
|
22
|
+
end
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
const sectionBody = (text, key) => {
|
|
26
|
+
const lines = linesOf(text);
|
|
27
|
+
const range = sectionRange(lines, key);
|
|
28
|
+
if (range === void 0) return;
|
|
29
|
+
return lines.slice(range.start + 1, range.end).join("\n");
|
|
30
|
+
};
|
|
31
|
+
const remainder = (text, key) => {
|
|
32
|
+
const lines = linesOf(text);
|
|
33
|
+
const range = sectionRange(lines, key);
|
|
34
|
+
if (range === void 0) return text;
|
|
35
|
+
return [...lines.slice(0, range.start), ...lines.slice(range.end)].join("\n");
|
|
36
|
+
};
|
|
37
|
+
const entryPattern = /^("?)([A-Za-z0-9@/_.+*-]+)\1:\s*(.+)$/u;
|
|
38
|
+
const scalarEntries = (text, key) => {
|
|
39
|
+
const body = sectionBody(text, key);
|
|
40
|
+
if (body === void 0) return;
|
|
41
|
+
const entries = [];
|
|
42
|
+
for (const line of body.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (trimmed === "" || trimmed.startsWith("#")) continue;
|
|
45
|
+
if (trimmed.startsWith("-")) return;
|
|
46
|
+
const matched = entryPattern.exec(trimmed);
|
|
47
|
+
const name = matched?.[2];
|
|
48
|
+
const value = matched?.[3];
|
|
49
|
+
if (name === void 0 || value === void 0) return;
|
|
50
|
+
entries.push([name, unquote(value)]);
|
|
51
|
+
}
|
|
52
|
+
return entries;
|
|
53
|
+
};
|
|
54
|
+
const drift = "drifted from the aas template; run aas sync";
|
|
55
|
+
const workspaceDrift = (template, actual) => {
|
|
56
|
+
if (remainder(template, "allowBuilds") !== remainder(actual, "allowBuilds")) return drift;
|
|
57
|
+
const expected = scalarEntries(template, "allowBuilds");
|
|
58
|
+
const found = scalarEntries(actual, "allowBuilds");
|
|
59
|
+
if (expected === void 0 || found === void 0) return drift;
|
|
60
|
+
for (const [key, value] of expected) {
|
|
61
|
+
const match = found.find((entry) => entry[0] === key);
|
|
62
|
+
if (match === void 0 || match[1] !== value) return drift;
|
|
63
|
+
}
|
|
64
|
+
return "";
|
|
65
|
+
};
|
|
66
|
+
const mergeAllowBuilds = (template, previous) => {
|
|
67
|
+
const expected = scalarEntries(template, "allowBuilds");
|
|
68
|
+
const found = scalarEntries(previous, "allowBuilds");
|
|
69
|
+
if (expected === void 0 || found === void 0) return template;
|
|
70
|
+
const extras = found.filter((entry) => !expected.some((required) => required[0] === entry[0])).sort((left, right) => left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0);
|
|
71
|
+
if (extras.length === 0) return template;
|
|
72
|
+
return `${template.endsWith("\n") ? template.slice(0, -1) : template}\n${extras.map(([key, value]) => ` ${key}: ${value}`).join("\n")}\n`;
|
|
73
|
+
};
|
|
74
|
+
const pairText = (entries) => entries.map(([key, value]) => `${key}=${value}`).sort().join("\n");
|
|
75
|
+
const sameScalars = (left, right) => pairText(left) === pairText(right);
|
|
76
|
+
export { mergeAllowBuilds, sameScalars, scalarEntries, workspaceDrift };
|
package/package.json
CHANGED
package/plugin/aas.mjs
CHANGED
|
@@ -24,15 +24,15 @@ const rawSpecifiers = new Set([
|
|
|
24
24
|
"fs",
|
|
25
25
|
"http",
|
|
26
26
|
"https",
|
|
27
|
-
"net",
|
|
28
27
|
"node:child_process",
|
|
29
28
|
"node:fs",
|
|
30
29
|
"node:fs/promises",
|
|
31
30
|
"node:http",
|
|
32
31
|
"node:https",
|
|
33
|
-
"node:net",
|
|
34
32
|
])
|
|
35
33
|
|
|
34
|
+
const socketSpecifiers = new Set(["net", "node:net", "node:tls", "tls"])
|
|
35
|
+
|
|
36
36
|
const consoleMethods = new Set(["debug", "error", "info", "log", "warn"])
|
|
37
37
|
|
|
38
38
|
const effectRunMethods = new Set(["runFork", "runPromise", "runPromiseExit", "runSync"])
|
|
@@ -410,27 +410,40 @@ const effectTests = {
|
|
|
410
410
|
},
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
const isAdapterFile = (context) => {
|
|
414
|
+
const filename = typeof context.filename === "string" ? context.filename : ""
|
|
415
|
+
return filename.endsWith(".adapter.ts")
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const rawMessage = (source) => {
|
|
419
|
+
if (socketSpecifiers.has(source)) {
|
|
420
|
+
return "node:net and node:tls belong in a .adapter.ts file; other raw IO stays banned"
|
|
421
|
+
}
|
|
422
|
+
return "raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess"
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const bannedRaw = (context, node, source) => {
|
|
426
|
+
if (socketSpecifiers.has(source)) {
|
|
427
|
+
if (isAdapterFile(context)) {
|
|
428
|
+
return
|
|
429
|
+
}
|
|
430
|
+
report(context, node, rawMessage(source))
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (rawSpecifiers.has(source)) {
|
|
434
|
+
report(context, node, rawMessage(source))
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
413
438
|
const noRawIo = {
|
|
414
439
|
meta: { type: "problem" },
|
|
415
440
|
create(context) {
|
|
416
441
|
return {
|
|
417
442
|
ImportDeclaration(node) {
|
|
418
|
-
|
|
419
|
-
report(
|
|
420
|
-
context,
|
|
421
|
-
node,
|
|
422
|
-
"raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess",
|
|
423
|
-
)
|
|
424
|
-
}
|
|
443
|
+
bannedRaw(context, node, specifier(node.source))
|
|
425
444
|
},
|
|
426
445
|
ImportExpression(node) {
|
|
427
|
-
|
|
428
|
-
report(
|
|
429
|
-
context,
|
|
430
|
-
node,
|
|
431
|
-
"raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess",
|
|
432
|
-
)
|
|
433
|
-
}
|
|
446
|
+
bannedRaw(context, node, specifier(node.source))
|
|
434
447
|
},
|
|
435
448
|
CallExpression(node) {
|
|
436
449
|
const callee = node.callee
|
|
@@ -438,16 +451,8 @@ const noRawIo = {
|
|
|
438
451
|
report(context, node, "global fetch is banned; use Effect HttpClient")
|
|
439
452
|
return
|
|
440
453
|
}
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
callee.name === "require" &&
|
|
444
|
-
rawSpecifiers.has(specifier(node.arguments[0]))
|
|
445
|
-
) {
|
|
446
|
-
report(
|
|
447
|
-
context,
|
|
448
|
-
node,
|
|
449
|
-
"raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess",
|
|
450
|
-
)
|
|
454
|
+
if (callee.type === "Identifier" && callee.name === "require") {
|
|
455
|
+
bannedRaw(context, node, specifier(node.arguments[0]))
|
|
451
456
|
return
|
|
452
457
|
}
|
|
453
458
|
if (callee.type !== "MemberExpression") {
|
package/templates/AGENTS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# AGENTS.md
|
|
2
2
|
|
|
3
|
-
This project uses aas-stack
|
|
3
|
+
This project uses `@aas-dev/stack`. The command is `aas`. The ideal is one way to do each thing, and `pnpm verify` is the score. A check that cannot run in this environment gets changed until it can. A check that passes while a bypass still works is too lax.
|
|
4
4
|
|
|
5
5
|
`aas verify` is the gate. `pnpm verify` calls it. On success it prints nothing. On failure it prints `file:line` diagnostics.
|
|
6
6
|
|
|
@@ -8,19 +8,31 @@ This project uses aas-stack. The ideal is one way to do each thing, and `pnpm ve
|
|
|
8
8
|
|
|
9
9
|
`aas verify` runs, in order:
|
|
10
10
|
|
|
11
|
-
1. A drift check. `.cursor/hooks.json`, `.vite-hooks/pre-commit`, `
|
|
11
|
+
1. A drift check. `.cursor/hooks.json`, `.vite-hooks/pre-commit`, `AGENTS.md`, `.github/workflows/verify.yml`, `vite.config.ts`, and `tsconfig.json` must match the templates from `aas init` byte for byte. `pnpm-workspace.yaml` must carry the same pins. Extra `allowBuilds` keys are allowed on that file only. `aas sync` rewrites the managed files and keeps those extra keys.
|
|
12
12
|
2. `varlock load`, when `.env.schema` is present, so the schema is valid.
|
|
13
13
|
3. `vp fmt --check` (Oxfmt, through Vite+).
|
|
14
14
|
4. `vp lint` (Oxlint, through Vite+, with the `aas` rules below).
|
|
15
15
|
5. `vp pack` (tsdown, through Vite+) for each library.
|
|
16
|
-
6. `tsc --noEmit` with the `@effect/tsgo` language service.
|
|
16
|
+
6. `effect-tsgo patch`, then `tsc --noEmit` with the `@effect/tsgo` language service.
|
|
17
17
|
7. `vp test` (`@effect/vitest` on the Vitest that ships with Vite+).
|
|
18
18
|
|
|
19
19
|
Vite+ does not replace the Effect typecheck. `vp check`'s type checker is tsgolint and does not run `@effect/language-service`. The patched `tsc` does.
|
|
20
20
|
|
|
21
21
|
The Cursor `beforeShellExecution` hook, the Vite+ pre-commit hook, and GitHub Actions all call this same gate. The hook is `failClosed`. CI does not set `continue-on-error`.
|
|
22
22
|
|
|
23
|
-
`NodeRuntime.runMain`
|
|
23
|
+
`NodeRuntime.runMain` is the process boundary. Everywhere else, stay inside Effect.
|
|
24
|
+
|
|
25
|
+
`tsconfig.json` extends `@aas-dev/stack/tsconfig.base.json`, which sets `noEmit`. `tsc` typechecks. A library build is `vp pack`, not `tsc` emit.
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
`aas init` writes the managed files, adds a `verify` script of `aas verify` when `package.json` has none, and runs `pnpm install` when `pnpm-lock.yaml` already exists so the lockfile picks up the template overrides. When `core.hooksPath` is already set, init prints that path and does not run `vp hooks enable`.
|
|
30
|
+
|
|
31
|
+
`aas sync` rewrites the managed files from the templates.
|
|
32
|
+
|
|
33
|
+
`aas explain` lists every rule. `aas explain <rule>` prints why that ban exists and the approved alternative. A rule id is `aas/no-throw` or `no-throw`.
|
|
34
|
+
|
|
35
|
+
`aas doctor` checks the Node and pnpm pins, the hooks path, lockfile override freshness, the patched `tsc`, and managed-file drift. It prints each check. It exits non-zero when one fails, and the line says what to run.
|
|
24
36
|
|
|
25
37
|
## Blessed patterns
|
|
26
38
|
|
|
@@ -43,7 +55,18 @@ yield * new EmptyText({})
|
|
|
43
55
|
|
|
44
56
|
### Config
|
|
45
57
|
|
|
46
|
-
|
|
58
|
+
Read values with `Config`. A value the project has to supply is declared in `.env.schema`, and that schema is the only place a literal default lives. Do not call `Config.withDefault`.
|
|
59
|
+
|
|
60
|
+
A computed runtime fallback does not belong in `.env.schema` and does not read `process.env`. `Config.orElse` runs the next `Config` when the key is absent. `Config.succeed` inside that callback builds the value when it is needed:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { homedir } from "node:os"
|
|
64
|
+
import { Config } from "effect"
|
|
65
|
+
|
|
66
|
+
export const dataDir = Config.String("DATA_DIR").pipe(
|
|
67
|
+
Config.orElse(() => Config.succeed(`${homedir()}/.aas`)),
|
|
68
|
+
)
|
|
69
|
+
```
|
|
47
70
|
|
|
48
71
|
`aas/no-process-env` rejects `process.env`, `process["env"]`, and `process?.env`.
|
|
49
72
|
|
|
@@ -80,7 +103,9 @@ export class AppConfig extends Context.Service<
|
|
|
80
103
|
|
|
81
104
|
Use Effect's `FileSystem`, `HttpClient`, `ChildProcess`, and `Console`. Stay inside Effect. Tests use `it.effect` instead of running the boundary.
|
|
82
105
|
|
|
83
|
-
`aas/no-raw-io` rejects `node:fs`, `node:fs/promises`, `fs`, `node:child_process`, `child_process`, `node:http`, `http`, `node:https`, `https`, `
|
|
106
|
+
`aas/no-raw-io` rejects `node:fs`, `node:fs/promises`, `fs`, `node:child_process`, `child_process`, `node:http`, `http`, `node:https`, `https`, `require` of those modules, dynamic `import` of those modules, global `fetch`, and `console.log` / `error` / `warn` / `info` / `debug`.
|
|
107
|
+
|
|
108
|
+
Effect 4.0.6 has no TCP or TLS client. `node:net`, `net`, `node:tls`, and `tls` are allowed only in a file whose name ends with `.adapter.ts`. That file is the Node entry for those sockets. `node:fs`, HTTP, child processes, `fetch`, and `console` stay banned there too. No command, flag, or comment grants the exception.
|
|
84
109
|
|
|
85
110
|
`aas/no-effect-run` rejects `Effect.runPromise`, `Effect.runSync`, `Effect.runFork`, and `Effect.runPromiseExit`.
|
|
86
111
|
|
|
@@ -109,7 +134,7 @@ External data is `unknown` until a `Schema` decodes it. Discriminate unions and
|
|
|
109
134
|
|
|
110
135
|
## Comments
|
|
111
136
|
|
|
112
|
-
`aas/no-comments` rejects a line comment, a block comment, and a JSDoc comment. The diagnostic tells the author to use clearer names or docs. A shebang line is not a comment. `/// <reference>` is a line comment, and `aas/no-bypass-comment` does not list it. `@ts-ignore`, `@ts-expect-error`, and `@ts-nocheck` are already `aas/no-bypass-comment` hits; `aas/no-comments` reports those comments as well. `@ts-check` is not a bypass marker.
|
|
137
|
+
`aas/no-comments` rejects a line comment, a block comment, and a JSDoc comment. The diagnostic tells the author to use clearer names or docs. A shebang line is not a comment. `/// <reference>` is a line comment, and `aas/no-bypass-comment` does not list it. `@ts-ignore`, `@ts-expect-error`, and `@ts-nocheck` are already `aas/no-bypass-comment` hits; `aas/no-comments` reports those comments as well. `@ts-check` is not a bypass marker.
|
|
113
138
|
|
|
114
139
|
## Bypasses the fence catches
|
|
115
140
|
|
|
@@ -119,14 +144,4 @@ Git cannot delete `--no-verify`. The agent hook rejects it anyway, including ins
|
|
|
119
144
|
|
|
120
145
|
## Ideal
|
|
121
146
|
|
|
122
|
-
One capability, one error style, one config source, one service style, one IO style, one test style.
|
|
123
|
-
|
|
124
|
-
## Vendored Effect
|
|
125
|
-
|
|
126
|
-
`pnpm vendor:agent-sources` clones the Effect version pinned by `@aas-stack/core` into `vendor/effect`. The clone is gitignored. `pnpm verify` does not clone.
|
|
127
|
-
|
|
128
|
-
## Not in this commit
|
|
129
|
-
|
|
130
|
-
HTTP with OpenAPI, MCP, and the Executor app adapter are next. The runtime constraint those adapters have to meet is already written in `docs/executor-runtime.md`. `kristoffer-aas/roborock-mcp` is the planned first consumer.
|
|
131
|
-
|
|
132
|
-
rat-stack and pstack are the inspiration for the fence and the TypeScript rules. This repo does not vendor their code, and it does not use XState or Alchemy.
|
|
147
|
+
One capability, one error style, one config source, one service style, one IO style, one test style. Blank text fails with a `Schema.TaggedError`. A counted value reports Unicode code points, whitespace-separated words, and `\n`-separated lines. `Config` and `Context.Service` are the config and service shapes. Climb toward more capabilities in that shape. Do not add a second pattern because the first one is inconvenient.
|
|
@@ -10,3 +10,14 @@ minimumReleaseAge: 0
|
|
|
10
10
|
overrides:
|
|
11
11
|
"vite@*": "npm:@voidzero-dev/vite-plus-core@1.4.0"
|
|
12
12
|
"vitest@*": "5.0.5"
|
|
13
|
+
|
|
14
|
+
peerDependencyRules:
|
|
15
|
+
allowAny:
|
|
16
|
+
- vite
|
|
17
|
+
- vitest
|
|
18
|
+
allowedVersions:
|
|
19
|
+
vite: "*"
|
|
20
|
+
vitest: "*"
|
|
21
|
+
|
|
22
|
+
allowBuilds:
|
|
23
|
+
esbuild: true
|