@aas-dev/stack 0.0.0-stage → 0.1.0

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/plugin/aas.mjs ADDED
@@ -0,0 +1,567 @@
1
+ const markers = [
2
+ "@ts-ignore",
3
+ "@ts-expect-error",
4
+ "@ts-nocheck",
5
+ "eslint-disable",
6
+ "eslint-enable",
7
+ "oxlint-disable",
8
+ "oxlint-enable",
9
+ ]
10
+
11
+ const errorNames = new Set([
12
+ "Error",
13
+ "AggregateError",
14
+ "EvalError",
15
+ "RangeError",
16
+ "ReferenceError",
17
+ "SyntaxError",
18
+ "TypeError",
19
+ "URIError",
20
+ ])
21
+
22
+ const rawSpecifiers = new Set([
23
+ "child_process",
24
+ "fs",
25
+ "http",
26
+ "https",
27
+ "net",
28
+ "node:child_process",
29
+ "node:fs",
30
+ "node:fs/promises",
31
+ "node:http",
32
+ "node:https",
33
+ "node:net",
34
+ ])
35
+
36
+ const consoleMethods = new Set(["debug", "error", "info", "log", "warn"])
37
+
38
+ const effectRunMethods = new Set(["runFork", "runPromise", "runPromiseExit", "runSync"])
39
+
40
+ const bannedTestMembers = new Set(["only", "skip", "skipIf", "todo"])
41
+
42
+ const testAliases = new Set(["fdescribe", "fit", "ftest", "xdescribe", "xit", "xtest"])
43
+
44
+ const report = (context, node, message) => {
45
+ context.report({ node, message })
46
+ }
47
+
48
+ const literalName = (property, computed) => {
49
+ if (!computed && property.type === "Identifier") {
50
+ return property.name
51
+ }
52
+ if (
53
+ computed &&
54
+ (property.type === "Literal" || property.type === "StringLiteral") &&
55
+ typeof property.value === "string"
56
+ ) {
57
+ return property.value
58
+ }
59
+ return ""
60
+ }
61
+
62
+ const isProcessEnv = (node) => {
63
+ if (node.type !== "MemberExpression") {
64
+ return false
65
+ }
66
+ return (
67
+ node.object.type === "Identifier" &&
68
+ node.object.name === "process" &&
69
+ literalName(node.property, node.computed) === "env"
70
+ )
71
+ }
72
+
73
+ const hasName = (node, name) => {
74
+ if (node === null || node === undefined || typeof node !== "object") {
75
+ return false
76
+ }
77
+ if (node.type === "Identifier" && node.name === name) {
78
+ return true
79
+ }
80
+ return (
81
+ hasName(node.callee, name) ||
82
+ hasName(node.object, name) ||
83
+ hasName(node.property, name) ||
84
+ hasName(node.expression, name)
85
+ )
86
+ }
87
+
88
+ const blessedSuper = (node) => hasName(node, "TaggedError") || hasName(node, "Service")
89
+
90
+ const specifier = (node) => {
91
+ if (node === null || node === undefined) {
92
+ return ""
93
+ }
94
+ if (
95
+ (node.type === "Literal" || node.type === "StringLiteral") &&
96
+ typeof node.value === "string"
97
+ ) {
98
+ return node.value
99
+ }
100
+ return ""
101
+ }
102
+
103
+ const chainParts = (callee) => {
104
+ const parts = []
105
+ let current = callee
106
+ while (current.type === "MemberExpression" || current.type === "ChainExpression") {
107
+ if (current.type === "ChainExpression") {
108
+ current = current.expression
109
+ continue
110
+ }
111
+ if (current.computed || current.property.type !== "Identifier") {
112
+ return []
113
+ }
114
+ parts.push(current.property.name)
115
+ current = current.object
116
+ }
117
+ if (current.type !== "Identifier") {
118
+ return []
119
+ }
120
+ parts.push(current.name)
121
+ parts.reverse()
122
+ return parts
123
+ }
124
+
125
+ const isItEffect = (parts) => {
126
+ if (parts[0] !== "it" || parts[1] !== "effect") {
127
+ return false
128
+ }
129
+ let index = 2
130
+ while (index < parts.length) {
131
+ const part = parts[index]
132
+ if (part !== undefined && bannedTestMembers.has(part)) {
133
+ return false
134
+ }
135
+ index += 1
136
+ }
137
+ return true
138
+ }
139
+
140
+ const flagModuleBinding = (context, statement) => {
141
+ if (statement === null || statement === undefined) {
142
+ return
143
+ }
144
+ if (statement.type === "VariableDeclaration" && statement.kind !== "const") {
145
+ report(
146
+ context,
147
+ statement,
148
+ "module-scope let and var are banned; keep state in a Context.Service",
149
+ )
150
+ }
151
+ const binding = statement.init ?? statement.left
152
+ if (
153
+ (statement.type === "ForStatement" ||
154
+ statement.type === "ForInStatement" ||
155
+ statement.type === "ForOfStatement") &&
156
+ binding !== null &&
157
+ binding !== undefined &&
158
+ binding.type === "VariableDeclaration" &&
159
+ binding.kind !== "const"
160
+ ) {
161
+ report(context, binding, "module-scope let and var are banned; keep state in a Context.Service")
162
+ }
163
+ }
164
+
165
+ const noThrow = {
166
+ meta: { type: "problem" },
167
+ create(context) {
168
+ return {
169
+ ThrowStatement(node) {
170
+ report(context, node, "throw is banned; yield a Schema.TaggedError")
171
+ },
172
+ }
173
+ },
174
+ }
175
+
176
+ const noTry = {
177
+ meta: { type: "problem" },
178
+ create(context) {
179
+ return {
180
+ TryStatement(node) {
181
+ report(context, node, "try is banned; failures stay on the Effect channel")
182
+ },
183
+ }
184
+ },
185
+ }
186
+
187
+ const noErrorClass = {
188
+ meta: { type: "problem" },
189
+ create(context) {
190
+ return {
191
+ NewExpression(node) {
192
+ const callee = node.callee
193
+ const name =
194
+ callee.type === "Identifier"
195
+ ? callee.name
196
+ : callee.type === "MemberExpression"
197
+ ? literalName(callee.property, callee.computed)
198
+ : ""
199
+ if (errorNames.has(name)) {
200
+ report(context, node, "new Error is banned; define a Schema.TaggedError")
201
+ }
202
+ },
203
+ }
204
+ },
205
+ }
206
+
207
+ const noProcessEnv = {
208
+ meta: { type: "problem" },
209
+ create(context) {
210
+ return {
211
+ MemberExpression(node) {
212
+ if (isProcessEnv(node)) {
213
+ report(
214
+ context,
215
+ node,
216
+ "process.env is banned; declare the variable in .env.schema and read it with Config",
217
+ )
218
+ }
219
+ },
220
+ }
221
+ },
222
+ }
223
+
224
+ const noTypeAssertion = {
225
+ meta: { type: "problem" },
226
+ create(context) {
227
+ return {
228
+ TSAsExpression(node) {
229
+ report(
230
+ context,
231
+ node,
232
+ "type assertions are banned, including as const; parse with Schema or use a const type parameter",
233
+ )
234
+ },
235
+ TSTypeAssertion(node) {
236
+ report(
237
+ context,
238
+ node,
239
+ "angle-bracket assertions are banned; parse with Schema or use a const type parameter",
240
+ )
241
+ },
242
+ }
243
+ },
244
+ }
245
+
246
+ const noExplicitAny = {
247
+ meta: { type: "problem" },
248
+ create(context) {
249
+ return {
250
+ TSAnyKeyword(node) {
251
+ report(context, node, "any is banned; use unknown and decode it with Schema")
252
+ },
253
+ }
254
+ },
255
+ }
256
+
257
+ const noNonNullAssertion = {
258
+ meta: { type: "problem" },
259
+ create(context) {
260
+ return {
261
+ TSNonNullExpression(node) {
262
+ report(
263
+ context,
264
+ node,
265
+ "non-null assertions are banned; narrow the value or decode it with Schema",
266
+ )
267
+ },
268
+ }
269
+ },
270
+ }
271
+
272
+ const textOf = (context) => {
273
+ const source = context.sourceCode
274
+ if (typeof source.text === "string") {
275
+ return source.text
276
+ }
277
+ if (typeof source.getText === "function") {
278
+ return source.getText()
279
+ }
280
+ return ""
281
+ }
282
+
283
+ const commentStart = (comment) => {
284
+ const range = comment.range
285
+ if (Array.isArray(range)) {
286
+ const start = range[0]
287
+ return typeof start === "number" ? start : -1
288
+ }
289
+ if (range !== null && range !== undefined && typeof range.start === "number") {
290
+ return range.start
291
+ }
292
+ if (typeof comment.start === "number") {
293
+ return comment.start
294
+ }
295
+ return -1
296
+ }
297
+
298
+ const isShebang = (comment, text) => {
299
+ if (comment.type === "Shebang" || comment.type === "Hashbang") {
300
+ return true
301
+ }
302
+ return text.startsWith("#!") && commentStart(comment) === 0
303
+ }
304
+
305
+ const noComments = {
306
+ meta: { type: "problem" },
307
+ create(context) {
308
+ const text = textOf(context)
309
+ return {
310
+ Program(node) {
311
+ for (const comment of context.sourceCode.getAllComments()) {
312
+ if (isShebang(comment, text)) {
313
+ continue
314
+ }
315
+ const message = "comments are banned; use clearer names or docs"
316
+ if (comment.loc === undefined || comment.loc === null) {
317
+ report(context, node, message)
318
+ } else {
319
+ context.report({ loc: comment.loc, message })
320
+ }
321
+ }
322
+ },
323
+ }
324
+ },
325
+ }
326
+
327
+ const noBypassComment = {
328
+ meta: { type: "problem" },
329
+ create(context) {
330
+ return {
331
+ Program(node) {
332
+ for (const comment of context.sourceCode.getAllComments()) {
333
+ let matched = ""
334
+ for (const marker of markers) {
335
+ if (comment.value.includes(marker)) {
336
+ matched = marker
337
+ break
338
+ }
339
+ }
340
+ if (matched === "") {
341
+ continue
342
+ }
343
+ const message = `directive comments are banned (${matched})`
344
+ if (comment.loc === undefined || comment.loc === null) {
345
+ report(context, node, message)
346
+ } else {
347
+ context.report({ loc: comment.loc, message })
348
+ }
349
+ }
350
+ },
351
+ }
352
+ },
353
+ }
354
+
355
+ const effectTests = {
356
+ meta: { type: "problem" },
357
+ create(context) {
358
+ const filename = typeof context.filename === "string" ? context.filename : ""
359
+ const isTest = filename.endsWith(".test.ts")
360
+ return {
361
+ ImportDeclaration(node) {
362
+ const source = specifier(node.source)
363
+ if (source === "vitest/config") {
364
+ if (!filename.endsWith("/vitest.config.ts") && !filename.endsWith("\\vitest.config.ts")) {
365
+ report(context, node, "import tests from @effect/vitest")
366
+ }
367
+ return
368
+ }
369
+ if (source === "vitest" || source.startsWith("vitest/") || source.startsWith("@vitest/")) {
370
+ report(context, node, "import tests from @effect/vitest")
371
+ }
372
+ },
373
+ CallExpression(node) {
374
+ if (!isTest) {
375
+ return
376
+ }
377
+ const parts = chainParts(node.callee)
378
+ const root = parts[0]
379
+ if (root === undefined) {
380
+ if (
381
+ node.callee.type === "Identifier" &&
382
+ (node.callee.name === "it" ||
383
+ node.callee.name === "test" ||
384
+ testAliases.has(node.callee.name))
385
+ ) {
386
+ report(context, node, "tests use it.effect from @effect/vitest")
387
+ }
388
+ return
389
+ }
390
+ if (isItEffect(parts)) {
391
+ return
392
+ }
393
+ if (root === "it" || root === "test" || testAliases.has(root)) {
394
+ report(context, node, "tests use it.effect from @effect/vitest")
395
+ return
396
+ }
397
+ if (root === "describe") {
398
+ let index = 1
399
+ while (index < parts.length) {
400
+ const part = parts[index]
401
+ if (part !== undefined && bannedTestMembers.has(part)) {
402
+ report(context, node, "skipped, focused, and todo tests are banned")
403
+ return
404
+ }
405
+ index += 1
406
+ }
407
+ }
408
+ },
409
+ }
410
+ },
411
+ }
412
+
413
+ const noRawIo = {
414
+ meta: { type: "problem" },
415
+ create(context) {
416
+ return {
417
+ ImportDeclaration(node) {
418
+ if (rawSpecifiers.has(specifier(node.source))) {
419
+ report(
420
+ context,
421
+ node,
422
+ "raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess",
423
+ )
424
+ }
425
+ },
426
+ ImportExpression(node) {
427
+ if (rawSpecifiers.has(specifier(node.source))) {
428
+ report(
429
+ context,
430
+ node,
431
+ "raw IO imports are banned; use Effect FileSystem, HttpClient, or ChildProcess",
432
+ )
433
+ }
434
+ },
435
+ CallExpression(node) {
436
+ const callee = node.callee
437
+ if (callee.type === "Identifier" && callee.name === "fetch") {
438
+ report(context, node, "global fetch is banned; use Effect HttpClient")
439
+ return
440
+ }
441
+ if (
442
+ callee.type === "Identifier" &&
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
+ )
451
+ return
452
+ }
453
+ if (callee.type !== "MemberExpression") {
454
+ return
455
+ }
456
+ const name = literalName(callee.property, callee.computed)
457
+ if (
458
+ callee.object.type === "Identifier" &&
459
+ callee.object.name === "console" &&
460
+ consoleMethods.has(name)
461
+ ) {
462
+ report(context, node, "console is banned; use Effect Console")
463
+ return
464
+ }
465
+ if (
466
+ callee.object.type === "Identifier" &&
467
+ (callee.object.name === "globalThis" ||
468
+ callee.object.name === "global" ||
469
+ callee.object.name === "window") &&
470
+ name === "fetch"
471
+ ) {
472
+ report(context, node, "global fetch is banned; use Effect HttpClient")
473
+ }
474
+ },
475
+ }
476
+ },
477
+ }
478
+
479
+ const noEffectRun = {
480
+ meta: { type: "problem" },
481
+ create(context) {
482
+ return {
483
+ CallExpression(node) {
484
+ const callee = node.callee
485
+ if (callee.type !== "MemberExpression") {
486
+ return
487
+ }
488
+ if (
489
+ callee.object.type === "Identifier" &&
490
+ callee.object.name === "Effect" &&
491
+ effectRunMethods.has(literalName(callee.property, callee.computed))
492
+ ) {
493
+ report(
494
+ context,
495
+ node,
496
+ "Effect.run* is banned; stay inside Effect and use it.effect in tests",
497
+ )
498
+ }
499
+ },
500
+ }
501
+ },
502
+ }
503
+
504
+ const noModuleLet = {
505
+ meta: { type: "problem" },
506
+ create(context) {
507
+ return {
508
+ Program(node) {
509
+ for (const statement of node.body) {
510
+ const declaration =
511
+ statement.type === "ExportNamedDeclaration" ? statement.declaration : statement
512
+ flagModuleBinding(context, declaration)
513
+ }
514
+ },
515
+ }
516
+ },
517
+ }
518
+
519
+ const noPlainClass = {
520
+ meta: { type: "problem" },
521
+ create(context) {
522
+ return {
523
+ ClassDeclaration(node) {
524
+ if (!blessedSuper(node.superClass)) {
525
+ report(
526
+ context,
527
+ node,
528
+ "plain classes are banned; extend Schema.TaggedError or Context.Service",
529
+ )
530
+ }
531
+ },
532
+ ClassExpression(node) {
533
+ if (!blessedSuper(node.superClass)) {
534
+ report(
535
+ context,
536
+ node,
537
+ "plain classes are banned; extend Schema.TaggedError or Context.Service",
538
+ )
539
+ }
540
+ },
541
+ }
542
+ },
543
+ }
544
+
545
+ const rules = {
546
+ "effect-tests": effectTests,
547
+ "no-bypass-comment": noBypassComment,
548
+ "no-comments": noComments,
549
+ "no-effect-run": noEffectRun,
550
+ "no-error-class": noErrorClass,
551
+ "no-explicit-any": noExplicitAny,
552
+ "no-module-let": noModuleLet,
553
+ "no-non-null-assertion": noNonNullAssertion,
554
+ "no-plain-class": noPlainClass,
555
+ "no-process-env": noProcessEnv,
556
+ "no-raw-io": noRawIo,
557
+ "no-throw": noThrow,
558
+ "no-try": noTry,
559
+ "no-type-assertion": noTypeAssertion,
560
+ }
561
+
562
+ export const ruleNames = Object.keys(rules)
563
+
564
+ export default {
565
+ meta: { name: "aas" },
566
+ rules,
567
+ }
@@ -0,0 +1,132 @@
1
+ # AGENTS.md
2
+
3
+ This project uses aas-stack. 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
+
5
+ `aas verify` is the gate. `pnpm verify` calls it. On success it prints nothing. On failure it prints `file:line` diagnostics.
6
+
7
+ ## The loop
8
+
9
+ `aas verify` runs, in order:
10
+
11
+ 1. A drift check. `.cursor/hooks.json`, `.vite-hooks/pre-commit`, `pnpm-workspace.yaml`, `AGENTS.md`, `.github/workflows/verify.yml`, `vite.config.ts`, and `tsconfig.json` must match the templates from `aas init`. `aas sync` rewrites them.
12
+ 2. `varlock load`, when `.env.schema` is present, so the schema is valid.
13
+ 3. `vp fmt --check` (Oxfmt, through Vite+).
14
+ 4. `vp lint` (Oxlint, through Vite+, with the `aas` rules below).
15
+ 5. `vp pack` (tsdown, through Vite+) for each library.
16
+ 6. `tsc --noEmit` with the `@effect/tsgo` language service.
17
+ 7. `vp test` (`@effect/vitest` on the Vitest that ships with Vite+).
18
+
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
+
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
+
23
+ `NodeRuntime.runMain` in `packages/cli/src/bin.ts` and `packages/cli/src/cursor-hook.ts` is the process boundary. Everywhere else, stay inside Effect.
24
+
25
+ ## Blessed patterns
26
+
27
+ One pattern per concern. The lint rule is what makes the other patterns unavailable.
28
+
29
+ ### Errors
30
+
31
+ Yield a `Schema.TaggedError`. Pass the brand parameter `@effect/tsgo` requires.
32
+
33
+ ```ts
34
+ export class EmptyText extends Schema.TaggedError<EmptyText, { readonly brand: unique symbol }>()(
35
+ "EmptyText",
36
+ {},
37
+ ) {}
38
+
39
+ yield * new EmptyText({})
40
+ ```
41
+
42
+ `aas/no-throw`, `aas/no-try`, and `aas/no-error-class` reject `throw`, `try`, and `new Error` (including the other built-in error constructors).
43
+
44
+ ### Config
45
+
46
+ Declare every variable in `.env.schema`. Read it with `Config`. The schema is the only place a default lives. Do not also call `Config.withDefault`.
47
+
48
+ `aas/no-process-env` rejects `process.env`, `process["env"]`, and `process?.env`.
49
+
50
+ Keep a string tuple without a type assertion:
51
+
52
+ ```ts
53
+ const literals = <const Values extends readonly string[]>(...values: Values): Values => values
54
+
55
+ export const AppEnv = Config.Literals(literals("development", "test", "production"), "APP_ENV")
56
+ ```
57
+
58
+ ### Services
59
+
60
+ Extend `Context.Service` and provide a `Layer`.
61
+
62
+ ```ts
63
+ export class AppConfig extends Context.Service<
64
+ AppConfig,
65
+ { readonly env: "development" | "test" | "production" }
66
+ >()("aas/AppConfig") {
67
+ static readonly layer = Layer.effect(
68
+ AppConfig,
69
+ Effect.gen(function* () {
70
+ const env = yield* AppEnv
71
+ return AppConfig.of({ env })
72
+ }),
73
+ )
74
+ }
75
+ ```
76
+
77
+ `aas/no-plain-class` rejects a class whose superclass is not `Schema.TaggedError` or `Context.Service`. `aas/no-module-let` rejects module-scope `let` and `var`. A binding inside a function is fine. State that outlives a call belongs on a service.
78
+
79
+ ### IO
80
+
81
+ Use Effect's `FileSystem`, `HttpClient`, `ChildProcess`, and `Console`. Stay inside Effect. Tests use `it.effect` instead of running the boundary.
82
+
83
+ `aas/no-raw-io` rejects `node:fs`, `node:fs/promises`, `fs`, `node:child_process`, `child_process`, `node:http`, `http`, `node:https`, `https`, `node:net`, `net`, `require` of those modules, dynamic `import` of those modules, global `fetch`, and `console.log` / `error` / `warn` / `info` / `debug`.
84
+
85
+ `aas/no-effect-run` rejects `Effect.runPromise`, `Effect.runSync`, `Effect.runFork`, and `Effect.runPromiseExit`.
86
+
87
+ ### Tests
88
+
89
+ ```ts
90
+ import { assert, describe, it } from "@effect/vitest"
91
+
92
+ it.effect("counts code points", () =>
93
+ Effect.gen(function* () {
94
+ const counted = yield* countText("👋")
95
+ assert.strictEqual(counted.characters, 1)
96
+ }),
97
+ )
98
+ ```
99
+
100
+ `aas/effect-tests` rejects imports from `vitest` or `@vitest/*`, plain `it()` / `test()`, `it.skip` / `it.only` / `it.todo` / `it.skipIf` / `it.live`, `describe.only`, and the aliases `fit`, `fdescribe`, `ftest`, `xit`, `xdescribe`, `xtest`. `it.effect` (and `it.effect.each` / `it.effect.prop`) is the runner. `describe` without a focus or skip modifier is fine.
101
+
102
+ Assert observable behavior. Do not mock the capability. A test that needs a process or the filesystem uses the Node `ChildProcess` and `FileSystem` layers and really runs them.
103
+
104
+ ### Types
105
+
106
+ `aas/no-explicit-any` rejects `any`. `aas/no-type-assertion` rejects `as` and angle-bracket assertions, including `as const`. `aas/no-non-null-assertion` rejects `!`. An import alias (`import { readFile as read }`) is not a type assertion.
107
+
108
+ External data is `unknown` until a `Schema` decodes it. Discriminate unions and exhaust them. Brand a value by parsing it. A `Schema.TaggedError` passes `{ readonly brand: unique symbol }` as its brand parameter. `@effect/tsgo` errors on leaked requirements, floating effects, global `Date` / `fetch` / `console`, Node built-in imports, and `instanceof` on a schema type (`Schema.is` is the check). `unstableApiUsage` is off because the Effect v4 APIs this stack requires are still marked unstable.
109
+
110
+ ## Comments
111
+
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. The fence test scans source text, skipping a leading shebang and quoted spans, so a comment the linter did not report still fails.
113
+
114
+ ## Bypasses the fence catches
115
+
116
+ `aas/no-bypass-comment` rejects comments that contain `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `eslint-disable`, `eslint-enable`, `oxlint-disable`, or `oxlint-enable`. A disable comment can silence the statement it covers, so the fence test also scans source text. A block comment that hides every oxlint diagnostic still fails that scan. The fixtures in `packages/fence/fixtures` are ignored by the normal lint so they can exist; `packages/fence` runs `vp lint` on them and asserts each rule id and count. Blessed fixtures must produce zero diagnostics.
117
+
118
+ Git cannot delete `--no-verify`. The agent hook rejects it anyway, including inside `bash -c "..."`. It also rejects a short option cluster that contains `n` on `commit`, `merge`, `rebase`, `cherry-pick`, `am`, and `pull` (`-n`, `-nm`, `-an`), and it rejects `LEFTHOOK=0`, `HUSKY=0`, `VP_GIT_HOOKS=0`, `VITE_GIT_HOOKS=0`, `vp hooks disable`, and `vp.hooks.disabled`. `git log -n 5`, `git push -n`, `git commit -m "hello -n"`, `vp hooks enable`, and `vp hooks status` stay allowed: `-n` is only special on the subcommands above, and a quoted message is not a flag. Unreadable hook input crashes the process, and `failClosed` turns that crash into a deny. The Vite+ pre-commit hook runs `pnpm exec aas verify` for a human commit. CI runs the same command even if the hook was skipped.
119
+
120
+ ## Ideal
121
+
122
+ One capability, one error style, one config source, one service style, one IO style, one test style. `@aas-stack/core` is that shape for one capability, `countText`: blank text fails with `EmptyText`, and a counted value reports Unicode code points, whitespace-separated words, and `\n`-separated lines. `AppEnv` and `AppConfig` are the config and service examples. Climb toward more capabilities in that shape. Do not add a second pattern because the first one is inconvenient.
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.
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "beforeShellExecution": [
5
+ {
6
+ "command": "node ./node_modules/@aas-dev/stack/dist/cursor-hook.js",
7
+ "failClosed": true
8
+ }
9
+ ]
10
+ }
11
+ }