@openora/create 0.4.1-canary.31 → 0.4.1-canary.310
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/dist/.tsbuildinfo +1 -1
- package/dist/generated/core-version.d.ts +1 -1
- package/dist/generated/core-version.d.ts.map +1 -1
- package/dist/generated/core-version.js +1 -1
- package/dist/generated/core-version.js.map +1 -1
- package/dist/index.js +47 -92
- package/dist/index.js.map +1 -1
- package/dist/render.d.ts +15 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +125 -0
- package/dist/render.js.map +1 -0
- package/package.json +7 -5
- package/template/README.md.tpl +12 -7
- package/template/__dot__claude/settings.json.tpl +22 -12
- package/template/__dot__env.example +10 -0
- package/template/__dot__gitignore +6 -1
- package/template/__dot__rulesync/commands/adr.md +32 -0
- package/template/__dot__rulesync/commands/check.md +11 -10
- package/template/__dot__rulesync/commands/doctor.md.tpl +17 -0
- package/template/__dot__rulesync/commands/scaffold-module.md +3 -2
- package/template/__dot__rulesync/commands/scaffold-plugin.md +19 -3
- package/template/__dot__rulesync/commands/scaffold-route.md +1 -0
- package/template/__dot__rulesync/commands/start.md +2 -2
- package/template/__dot__rulesync/hooks/_shared.mjs +5 -0
- package/template/__dot__rulesync/hooks/guard-core.mjs.tpl +75 -26
- package/template/__dot__rulesync/hooks/guard-generated.mjs +12 -5
- package/template/__dot__rulesync/hooks/guard-mr.mjs +81 -0
- package/template/__dot__rulesync/hooks/guard-subagent.mjs +22 -6
- package/template/__dot__rulesync/hooks/post-edit.mjs.tpl +106 -0
- package/template/__dot__rulesync/hooks.json +27 -4
- package/template/__dot__rulesync/rules/conventions.md +114 -0
- package/template/__dot__rulesync/rules/db-conventions.md +6 -72
- package/template/__dot__rulesync/rules/e2e-conventions.md +52 -0
- package/template/__dot__rulesync/rules/frontend-conventions.md.tpl +35 -0
- package/template/__dot__rulesync/rules/oss-boundaries.md.tpl +78 -0
- package/template/__dot__rulesync/rules/overview.md +18 -73
- package/template/__dot__rulesync/skills/create-plugin/{SKILL.md → SKILL.md.tpl} +13 -24
- package/template/__dot__rulesync/skills/create-pr/SKILL.md.tpl +38 -0
- package/template/__dot__rulesync/skills/create-task/{SKILL.md → SKILL.md.tpl} +14 -22
- package/template/__dot__rulesync/skills/create-ui-module/SKILL.md.tpl +60 -0
- package/template/__dot__rulesync/skills/enhance-prompt/{SKILL.md → SKILL.md.tpl} +4 -4
- package/template/__dot__rulesync/skills/handoff/SKILL.md +95 -0
- package/template/__dot__rulesync/skills/review/SKILL.md.tpl +277 -0
- package/template/__dot__rulesync/skills/ship/SKILL.md.tpl +149 -0
- package/template/__dot__rulesync/skills/ship/handoff.md.tpl +48 -0
- package/template/__dot__rulesync/skills/ship/watch-pr.mjs +168 -0
- package/template/__dot__rulesync/subagents/builder.md.tpl +115 -0
- package/template/__dot__rulesync/subagents/compliance-reviewer.md +78 -0
- package/template/__dot__rulesync/subagents/debugger.md.tpl +86 -0
- package/template/__dot__rulesync/subagents/deployer.md.tpl +54 -0
- package/template/__dot__rulesync/subagents/expert.md +28 -34
- package/template/__dot__rulesync/subagents/planner.md.tpl +39 -0
- package/template/__dot__rulesync/subagents/quality-reviewer.md.tpl +112 -0
- package/template/__dot__rulesync/subagents/security-reviewer.md.tpl +85 -0
- package/template/__dot__rulesync/subagents/tester.md.tpl +88 -0
- package/template/__dot__rulesync/sync.json.tpl +26 -0
- package/template/apps/api/package.json.tpl +1 -2
- package/template/apps/api/src/extensions.config.ts.tpl +12 -0
- package/template/apps/api/src/main.ts.tpl +1 -4
- package/template/apps/api/src/seed.ts.tpl +8 -1
- package/template/docker-compose.yml +13 -0
- package/template/docs/agents/cross-repo.md.tpl +37 -0
- package/template/docs/agents/forge.md.tpl +54 -0
- package/template/docs/agents/issue-tracker.md.tpl +42 -0
- package/template/docs/standards/comments.md +16 -0
- package/template/docs/standards/database.md +77 -0
- package/template/docs/standards/enforcement.md +12 -0
- package/template/docs/standards/errors.md +26 -0
- package/template/docs/standards/frontend.md +60 -0
- package/template/docs/standards/functions.md +38 -0
- package/template/docs/standards/git-delivery.md +20 -0
- package/template/docs/standards/module-structure.md +37 -0
- package/template/docs/standards/testing.md +72 -0
- package/template/docs/standards/types.md +31 -0
- package/template/package.json.tpl +11 -5
- package/template/rulesync.jsonc +1 -1
- package/template/tools/oss-worktree.mjs.tpl +105 -0
- package/template/tools/review-precheck.mjs +531 -0
- package/template/tools/sync-agents.mjs +257 -0
- package/template/turbo/generators/config.ts +1 -1
- package/template/turbo.json +14 -2
- package/template/__dot__rulesync/hooks/post-edit.mjs +0 -57
- package/template/__dot__rulesync/skills/add-feature/SKILL.md +0 -113
- package/template/__dot__rulesync/skills/add-feature/handoff.md +0 -58
- package/template/__dot__rulesync/skills/code-review/SKILL.md +0 -111
- package/template/__dot__rulesync/skills/create-pr/SKILL.md +0 -55
- package/template/__dot__rulesync/subagents/builder.md +0 -93
- package/template/__dot__rulesync/subagents/debugger.md +0 -83
- package/template/__dot__rulesync/subagents/deployer.md +0 -66
- package/template/__dot__rulesync/subagents/qa.md +0 -88
- package/template/apps/api/src/migrate.ts.tpl +0 -46
|
@@ -1,44 +1,93 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// PreToolUse guard (Claude / Copilot CLI / Codex CLI / Gemini CLI
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// PreToolUse guard (Claude / Copilot CLI / Codex CLI / Gemini CLI - rulesync renders the same
|
|
3
|
+
// script for every hook target, so the rule below holds identically in each tool).
|
|
4
|
+
// Enforces the HARD RULE: the linked OSS checkout and node_modules are read-only. The one
|
|
5
|
+
// exception is a git worktree under <oss>/.worktrees/ - the sanctioned place to change OSS
|
|
6
|
+
// code from this repo (oss-boundaries rule, `pnpm oss:worktree`). Reads are always allowed.
|
|
6
7
|
|
|
8
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
9
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
7
11
|
import { extractCommand, extractFilePath, readPayload, deny } from './_shared.mjs';
|
|
8
12
|
|
|
9
13
|
const payload = readPayload();
|
|
10
14
|
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
+
// Paths resolve from the repo root (this file lives in <root>/.rulesync/hooks/), so an
|
|
16
|
+
// absolute path - what most tools send - is caught as well as the relative form.
|
|
17
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
18
|
+
const OSS_RELATIVE = String.raw`{{ossFromRoot}}`;
|
|
19
|
+
// This repo is often checked out as a git worktree, whose `.git` is a file pointing at
|
|
20
|
+
// <main>/.git/worktrees/<name>. The OSS checkout is a sibling of the MAIN checkout, so without
|
|
21
|
+
// this the guard would resolve it inside the worktree, match nothing, and let core writes pass.
|
|
22
|
+
const mainCheckoutOf = (dir) => {
|
|
23
|
+
try {
|
|
24
|
+
const pointer = readFileSync(join(dir, '.git'), 'utf8');
|
|
25
|
+
const gitdir = pointer.match(/^gitdir:\s*(.*[/\\]worktrees[/\\][^/\\]+?)\s*$/m)?.[1];
|
|
26
|
+
return gitdir ? resolve(dir, gitdir, '..', '..', '..') : dir;
|
|
27
|
+
} catch {
|
|
28
|
+
return dir; // `.git` is a directory (the main checkout) or missing
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const OSS = resolve(mainCheckoutOf(ROOT), OSS_RELATIVE);
|
|
32
|
+
|
|
33
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
34
|
+
const OSS_PATH = `(?:${escape(OSS)}|${escape(OSS_RELATIVE)})`;
|
|
35
|
+
const PROTECTED = `(?:${OSS_PATH}|node_modules\\b)`;
|
|
36
|
+
// A path into a worktree with no `..` hop and no node_modules segment is sanctioned; it is
|
|
37
|
+
// masked out before the write checks run, so the rest of the command is still judged.
|
|
38
|
+
const WORKTREE_TOKEN = new RegExp(
|
|
39
|
+
String.raw`${OSS_PATH}/\.worktrees/(?![^\s'"|;&]*(?:\.\.|node_modules))[^\s'"|;&]*`,
|
|
40
|
+
'g',
|
|
41
|
+
);
|
|
42
|
+
// cp, install, and rsync write only to their last argument, so copying out of core stays a read.
|
|
43
|
+
const LAST_ARG = String.raw`\s['"]?[^'"\s|;&]*${PROTECTED}[^'"\s|;&]*['"]?(?:\s+\d?>\S*)*\s*(?:[|;&]|$)`;
|
|
44
|
+
const GIT_WRITES = 'apply|am|checkout|restore|reset|clean|stash|merge|rebase|pull|switch|cherry-pick|revert|rm|mv';
|
|
45
|
+
|
|
46
|
+
const HOW_TO_CHANGE_OSS =
|
|
47
|
+
'To change OSS code, run `pnpm oss:worktree <branch>` and edit inside ' +
|
|
48
|
+
`${OSS_RELATIVE}/.worktrees/ (rule: oss-boundaries). Otherwise extend from the OUTSIDE ` +
|
|
49
|
+
'(overlay plugin, adapter rebinding, UI plugin, config).';
|
|
15
50
|
|
|
16
|
-
// 1) Shell command writing into a protected path (sed -i, redirect, tee, rm, ...).
|
|
17
|
-
const command = extractCommand(payload);
|
|
51
|
+
// 1) Shell command writing into a protected path (sed -i, redirect, tee, cp, rm, git, ...).
|
|
52
|
+
const command = extractCommand(payload).replace(WORKTREE_TOKEN, '<oss-worktree>');
|
|
18
53
|
if (command) {
|
|
19
54
|
const writeToCore = [
|
|
20
|
-
[new RegExp(String.raw`\b(?:sed|perl)\b[^|;&]*\s-\w*i\w*\b[^|;&]
|
|
21
|
-
[new RegExp(String.raw`(?:>>?|>\|)\s*['"]?[^'"\s|;&]
|
|
22
|
-
[new RegExp(String.raw`\btee\b\s+(?:-a\s+)?['"]?[^'"\s|;&]
|
|
23
|
-
[new RegExp(String.raw
|
|
55
|
+
[new RegExp(String.raw`\b(?:sed|perl)\b[^|;&]*\s-\w*i\w*\b[^|;&]*${PROTECTED}`), 'in-place edit (sed/perl -i)'],
|
|
56
|
+
[new RegExp(String.raw`(?:>>?|>\|)\s*['"]?[^'"\s|;&]*${PROTECTED}`), 'shell redirection'],
|
|
57
|
+
[new RegExp(String.raw`\btee\b\s+(?:-a\s+)?['"]?[^'"\s|;&]*${PROTECTED}`), 'tee'],
|
|
58
|
+
[new RegExp(String.raw`(?<![\w-])(?:cp|install|rsync)\b[^|;&]*${LAST_ARG}`), 'copy'],
|
|
59
|
+
[new RegExp(String.raw`(?<![\w-])(?:rm|truncate|dd|chmod|chown|unlink|shred|mv|ln|patch)\b[^|;&]*${PROTECTED}`), 'destructive file op'],
|
|
60
|
+
[new RegExp(String.raw`\bgit\b[^|;&]*${PROTECTED}[^|;&]*\s(?:${GIT_WRITES})\b`), 'git command'],
|
|
24
61
|
];
|
|
25
|
-
const hit = writeToCore.find(([
|
|
62
|
+
const hit = writeToCore.find(([pattern]) => pattern.test(command));
|
|
26
63
|
if (hit) {
|
|
27
|
-
deny(
|
|
28
|
-
`Blocked: this ${hit[1]} writes into OSS core / node_modules, which is read-only here. ` +
|
|
29
|
-
'Extend the platform from the OUTSIDE (overlay plugin, adapter rebinding, UI plugin, config). ' +
|
|
30
|
-
'If it can only be fixed in core, STOP and report it as an upstream OSS issue.',
|
|
31
|
-
);
|
|
64
|
+
deny(`Blocked: this ${hit[1]} writes into the OSS checkout or node_modules, which are read-only here. ${HOW_TO_CHANGE_OSS}`);
|
|
32
65
|
}
|
|
33
66
|
}
|
|
34
67
|
|
|
35
|
-
//
|
|
68
|
+
// The path on disk, symlinks followed, so a link in this repo that points into the checkout
|
|
69
|
+
// does not pass as a local path. A file not yet created resolves through its nearest existing
|
|
70
|
+
// ancestor.
|
|
71
|
+
const physical = (path) => {
|
|
72
|
+
try {
|
|
73
|
+
return realpathSync(path);
|
|
74
|
+
} catch {
|
|
75
|
+
const parent = dirname(path);
|
|
76
|
+
return parent === path ? path : join(physical(parent), basename(path));
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// 2) Direct file edit/write whose target resolves inside a protected path.
|
|
36
81
|
const filePath = extractFilePath(payload);
|
|
37
|
-
if (filePath
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
);
|
|
82
|
+
if (filePath) {
|
|
83
|
+
const written = resolve(payload.cwd ?? process.cwd(), filePath);
|
|
84
|
+
const target = physical(written);
|
|
85
|
+
const oss = physical(OSS);
|
|
86
|
+
const isInside = (dir) => target.startsWith(dir + sep);
|
|
87
|
+
const inNodeModules = [written, target].some((path) => /(?:^|[\\/])node_modules(?:[\\/]|$)/.test(path));
|
|
88
|
+
if (inNodeModules || (isInside(oss) && !isInside(join(oss, '.worktrees')))) {
|
|
89
|
+
deny(`Blocked: ${filePath} is inside the OSS checkout or node_modules, which are read-only here. ${HOW_TO_CHANGE_OSS}`);
|
|
90
|
+
}
|
|
42
91
|
}
|
|
43
92
|
|
|
44
93
|
process.exit(0);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// PreToolUse guard (Claude / Copilot CLI / Codex CLI).
|
|
3
|
-
// Blocks edits to files generated by rulesync (`pnpm
|
|
2
|
+
// PreToolUse guard (Claude / Copilot CLI / Codex CLI / Gemini CLI).
|
|
3
|
+
// Blocks edits to files generated by rulesync (`pnpm gen:agents`). The single
|
|
4
4
|
// source of truth is .rulesync/; editing a generated mirror is lost on the next
|
|
5
5
|
// sync. Fail-open: only a positive match denies.
|
|
6
6
|
|
|
@@ -9,25 +9,32 @@ import { relative, isAbsolute } from 'node:path';
|
|
|
9
9
|
|
|
10
10
|
const payload = readPayload();
|
|
11
11
|
const filePath = extractFilePath(payload);
|
|
12
|
-
if (!filePath)
|
|
12
|
+
if (!filePath) {
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
13
15
|
|
|
16
|
+
// Repo-relative (hooks run with cwd = repo root) - name-agnostic.
|
|
14
17
|
const rel = isAbsolute(filePath) ? relative(process.cwd(), filePath) : filePath;
|
|
15
18
|
|
|
19
|
+
// Everything rulesync generates from .rulesync/.
|
|
16
20
|
const generated = [
|
|
17
21
|
/^AGENTS\.md$/,
|
|
18
22
|
/^CLAUDE\.md$/,
|
|
23
|
+
/^GEMINI\.md$/,
|
|
19
24
|
/^\.github\/copilot-instructions\.md$/,
|
|
20
25
|
/^\.mcp\.json$/,
|
|
21
26
|
/^\.vscode\/mcp\.json$/,
|
|
22
27
|
/^\.codex\/config\.toml$/,
|
|
28
|
+
/^\.gemini\/settings\.json$/,
|
|
23
29
|
/^\.claude\/(agents|commands)\//,
|
|
24
30
|
/^\.github\/(agents|prompts)\//,
|
|
31
|
+
/^\.gemini\/(agents|commands)\//,
|
|
25
32
|
];
|
|
26
33
|
|
|
27
34
|
if (generated.some((re) => re.test(rel))) {
|
|
28
35
|
deny(
|
|
29
|
-
`Blocked: ${rel} is generated by rulesync (\`pnpm
|
|
30
|
-
'Edit the source under .rulesync/ instead (rules/, subagents/, commands/, mcp.json), then run `pnpm
|
|
36
|
+
`Blocked: ${rel} is generated by rulesync (\`pnpm gen:agents\`). ` +
|
|
37
|
+
'Edit the source under .rulesync/ instead (rules/, subagents/, commands/, mcp.json), then run `pnpm gen:agents`.',
|
|
31
38
|
);
|
|
32
39
|
}
|
|
33
40
|
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PreToolUse guard: no merge/pull request until `pnpm verify` passed on the commit being sent.
|
|
3
|
+
// `pnpm verify` ends with `node .rulesync/hooks/guard-mr.mjs --stamp`, which records HEAD in
|
|
4
|
+
// node_modules/.cache/verify-head when the tree is clean; a `--stamp` outside the verify script is
|
|
5
|
+
// refused. Opening an MR/PR (CLI, push option, REST or GraphQL) is denied unless that record matches
|
|
6
|
+
// the current HEAD of a clean tree. Git errors fail open, like every other guard.
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { deny, extractCommand, readPayload } from './_shared.mjs';
|
|
12
|
+
|
|
13
|
+
const OPENS_REVIEW =
|
|
14
|
+
/\b(?:glab\s+mr\s+create|gh\s+pr\s+create|createPullRequest|mergeRequestCreate)\b|merge_request\.create/;
|
|
15
|
+
const API_CALL = /\b(?:gh|glab)\s+api\b/;
|
|
16
|
+
const REVIEW_COLLECTION = /\/(?:pulls|merge_requests)(?=["'\s?]|$)/;
|
|
17
|
+
// `gh api` and `glab api` default to POST once a field or body is passed.
|
|
18
|
+
const WRITES = /\s(?:(?:-X\s*|--method[=\s]+)POST\b|(?:-f|-F|--field|--raw-field|--input)\b)/i;
|
|
19
|
+
const READS = /\s(?:-X\s*|--method[=\s]+)GET\b/i;
|
|
20
|
+
|
|
21
|
+
const opensReview = (command) =>
|
|
22
|
+
OPENS_REVIEW.test(command) ||
|
|
23
|
+
(API_CALL.test(command) &&
|
|
24
|
+
REVIEW_COLLECTION.test(command) &&
|
|
25
|
+
WRITES.test(command) &&
|
|
26
|
+
!READS.test(command));
|
|
27
|
+
|
|
28
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
29
|
+
const stampFile = (root) => join(root, 'node_modules', '.cache', 'verify-head');
|
|
30
|
+
|
|
31
|
+
const checkout = (cwd) => {
|
|
32
|
+
try {
|
|
33
|
+
const root = git(cwd, 'rev-parse', '--show-toplevel');
|
|
34
|
+
return {
|
|
35
|
+
root,
|
|
36
|
+
head: git(root, 'rev-parse', 'HEAD'),
|
|
37
|
+
isClean: git(root, 'status', '--porcelain') === '',
|
|
38
|
+
};
|
|
39
|
+
} catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (process.argv.includes('--stamp')) {
|
|
45
|
+
if (process.env.npm_lifecycle_event !== 'verify') {
|
|
46
|
+
process.stderr.write('--stamp is recorded by `pnpm verify` only: run `pnpm verify`.\n');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
const state = checkout(process.cwd());
|
|
50
|
+
if (state?.isClean) {
|
|
51
|
+
mkdirSync(dirname(stampFile(state.root)), { recursive: true });
|
|
52
|
+
writeFileSync(stampFile(state.root), state.head);
|
|
53
|
+
} else {
|
|
54
|
+
process.stderr.write(
|
|
55
|
+
'verify passed on uncommitted changes: commit, then rerun `pnpm verify` before opening an MR/PR.\n',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const payload = readPayload();
|
|
62
|
+
if (!opensReview(extractCommand(payload))) {
|
|
63
|
+
process.exit(0);
|
|
64
|
+
}
|
|
65
|
+
const state = checkout(payload.cwd ?? process.cwd());
|
|
66
|
+
if (!state) {
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
const readStamp = () => {
|
|
70
|
+
try {
|
|
71
|
+
return readFileSync(stampFile(state.root), 'utf8').trim();
|
|
72
|
+
} catch {
|
|
73
|
+
return '';
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
if (!state.isClean || readStamp() !== state.head) {
|
|
77
|
+
deny(
|
|
78
|
+
'Blocked: run `pnpm verify` on a clean, committed HEAD before opening a merge/pull request. It records the verified commit; this check compares it with HEAD.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
process.exit(0);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// PreToolUse(Task) guard: keep the orchestrator from spawning a GENERIC subagent
|
|
3
3
|
// (general-purpose / claude) for work a roster agent is purpose-built for. The
|
|
4
|
-
// Agent roster in
|
|
5
|
-
// is the deterministic backstop.
|
|
4
|
+
// Agent roster in the workflow rule is prose the model sometimes skips out of
|
|
5
|
+
// habit; this is the deterministic backstop.
|
|
6
6
|
//
|
|
7
7
|
// Conservative by design: it acts ONLY when (a) the chosen subagent is generic AND
|
|
8
8
|
// (b) the task text contains a HIGH-SIGNAL phrase that maps unambiguously to one
|
|
@@ -16,14 +16,16 @@ const ti = payload.tool_input ?? {};
|
|
|
16
16
|
const sub = String(ti.subagent_type ?? '').toLowerCase();
|
|
17
17
|
|
|
18
18
|
const GENERIC = new Set(['general-purpose', 'claude', '']);
|
|
19
|
-
if (!GENERIC.has(sub))
|
|
19
|
+
if (!GENERIC.has(sub)) {
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
20
22
|
|
|
21
23
|
const text = `${ti.description ?? ''}\n${ti.prompt ?? ''}`.toLowerCase();
|
|
22
24
|
|
|
23
25
|
// High-signal phrase -> the roster agent that owns it. Keep each pattern SPECIFIC;
|
|
24
26
|
// err toward missing a case over false-blocking a generic task.
|
|
25
27
|
const ROUTES = [
|
|
26
|
-
{ agent: '
|
|
28
|
+
{ agent: 'tester', re: /\b(playwright|e2e test|end-to-end test|e2e coverage)\b/ },
|
|
27
29
|
{
|
|
28
30
|
agent: 'debugger',
|
|
29
31
|
re: /\b(build (failure|error)|turbopack|tsc error|module resolution|runtime error|stack trace)\b/,
|
|
@@ -40,14 +42,28 @@ const ROUTES = [
|
|
|
40
42
|
agent: 'deployer',
|
|
41
43
|
re: /\b(dockerfile|containerize|deploy pipeline|deploy to (ecs|kubernetes|fly|railway|render)|ci\/cd deploy|helm chart)\b/,
|
|
42
44
|
},
|
|
45
|
+
{
|
|
46
|
+
agent: 'compliance-reviewer',
|
|
47
|
+
re: /\b(compliance|responsible gambling|ledger|audit trail) review\b/,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
agent: 'quality-reviewer',
|
|
51
|
+
re: /\b(quality review|code quality|over-engineer(ed|ing)?|simplification review|duplication review)\b|\breview (the )?(mr|pr|diff|changed files)\b/,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
agent: 'security-reviewer',
|
|
55
|
+
re: /\bsecurity (review|audit)\b|\baudit\b[^.]*\bvulnerabilit|\b(authz|owasp)\b/,
|
|
56
|
+
},
|
|
43
57
|
];
|
|
44
58
|
|
|
45
59
|
const hit = ROUTES.find((r) => r.re.test(text));
|
|
46
|
-
if (!hit)
|
|
60
|
+
if (!hit) {
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
47
63
|
|
|
48
64
|
process.stderr.write(
|
|
49
65
|
`Use the \`${hit.agent}\` subagent for this task, not \`${sub || 'general-purpose'}\`. ` +
|
|
50
|
-
`It is pre-scoped for this work (tools + model + brief) - see the
|
|
66
|
+
`It is pre-scoped for this work (tools + model + brief) - see the Agents list in the workflow rule. ` +
|
|
51
67
|
`Re-issue the Task with subagent_type: "${hit.agent}". ` +
|
|
52
68
|
`If it genuinely does not fit ${hit.agent}, rephrase the description to say why.\n`,
|
|
53
69
|
);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse hook (Claude / Copilot CLI / Codex CLI / Gemini CLI).
|
|
3
|
+
// Lint-fixes the edited file, then typechecks the app that owns it and reports
|
|
4
|
+
// (exit 2) only if the error is in the file just edited. Output is capped so a
|
|
5
|
+
// failure can't balloon the model context. Fail-open on anything unexpected.
|
|
6
|
+
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { join, isAbsolute, relative } from 'node:path';
|
|
10
|
+
import { extractFilePath, readPayload } from './_shared.mjs';
|
|
11
|
+
|
|
12
|
+
const CAP_LINES = 40;
|
|
13
|
+
const CAP_CHARS = 2000;
|
|
14
|
+
|
|
15
|
+
const filePath = extractFilePath(readPayload());
|
|
16
|
+
if (!filePath) {
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
if (!/\.(ts|tsx)$/.test(filePath) || filePath.endsWith('.d.ts')) {
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
if (filePath.includes('/templates/') || filePath.includes('/generated/')) {
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* node_modules/.bin/<name> when it exists, else the bare name off PATH. `pnpm exec`
|
|
28
|
+
* costs ~2.2s of package-manager startup per call against ~0.12s for the binary, and
|
|
29
|
+
* this hook runs on every edit.
|
|
30
|
+
*/
|
|
31
|
+
function bin(name) {
|
|
32
|
+
const local = join(process.env.CLAUDE_PROJECT_DIR ?? process.cwd(), 'node_modules/.bin', name);
|
|
33
|
+
return existsSync(local) ? local : name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Lint-fix (best effort - never block on the linter).
|
|
37
|
+
try {
|
|
38
|
+
execFileSync(bin('oxlint'), ['--fix', filePath], { stdio: 'pipe' });
|
|
39
|
+
} catch {
|
|
40
|
+
/* oxlint unavailable or errored - fall through to the re-lint / typecheck */
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Re-lint WITHOUT --fix to surface UNFIXABLE errors the agent must resolve by
|
|
44
|
+
// hand - import/no-cycle and the no-restricted-imports boundary rules (deep @openora
|
|
45
|
+
// or {{scope}}/ui imports, app-to-app imports). oxlint exits non-zero on errors
|
|
46
|
+
// only (warnings don't fail), so this blocks on a real boundary/cycle violation
|
|
47
|
+
// in the edited file and feeds the message back. Fail-open on anything else.
|
|
48
|
+
try {
|
|
49
|
+
execFileSync(bin('oxlint'), [filePath], { stdio: 'pipe' });
|
|
50
|
+
} catch (e) {
|
|
51
|
+
const out = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '');
|
|
52
|
+
if (/\berror\b/.test(out)) {
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
`Boundary/lint error in ${filePath} - fix before continuing:\n${cap(out)}`,
|
|
55
|
+
);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolve the owning workspace app (apps/<x> or packages/<x>).
|
|
61
|
+
function packageNameFor(fp) {
|
|
62
|
+
const m = fp.match(/(.*?\/(?:apps|packages)\/[^/]+)\//);
|
|
63
|
+
if (!m) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const pkg = JSON.parse(readFileSync(join(m[1], 'package.json'), 'utf8'));
|
|
68
|
+
if (!pkg.name || !pkg.scripts?.['check:types']) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
return pkg.name;
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const abs = isAbsolute(filePath) ? filePath : join(process.cwd(), filePath);
|
|
78
|
+
const owner = packageNameFor(abs);
|
|
79
|
+
if (!owner) {
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cap(text) {
|
|
84
|
+
const out = text.split('\n').slice(0, CAP_LINES).join('\n').slice(0, CAP_CHARS);
|
|
85
|
+
return out.length < text.length ? `${out}\n... (truncated)` : out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
execFileSync(
|
|
90
|
+
bin('turbo'),
|
|
91
|
+
['run', 'check:types', '--filter', owner, '--output-logs=errors-only', '--ui=stream'],
|
|
92
|
+
{ stdio: 'pipe' },
|
|
93
|
+
);
|
|
94
|
+
process.exit(0);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
const output = (e.stdout?.toString() ?? '') + (e.stderr?.toString() ?? '');
|
|
97
|
+
const basename =
|
|
98
|
+
(isAbsolute(filePath) ? relative(process.cwd(), filePath) : filePath).split('/').pop() ?? '';
|
|
99
|
+
if (basename && output.includes(basename)) {
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`Typecheck failed for ${owner} after editing ${basename}:\n${cap(output)}`,
|
|
102
|
+
);
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
@@ -2,10 +2,33 @@
|
|
|
2
2
|
"version": 1,
|
|
3
3
|
"hooks": {
|
|
4
4
|
"preToolUse": [
|
|
5
|
-
{
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
|
|
8
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-core.mjs\""
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"type": "command",
|
|
12
|
+
"matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
|
|
13
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-generated.mjs\""
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"type": "command",
|
|
17
|
+
"matcher": "Bash",
|
|
18
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-mr.mjs\""
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"type": "command",
|
|
22
|
+
"matcher": "Task|Agent",
|
|
23
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/guard-subagent.mjs\""
|
|
24
|
+
}
|
|
8
25
|
],
|
|
9
|
-
"postToolUse": [
|
|
26
|
+
"postToolUse": [
|
|
27
|
+
{
|
|
28
|
+
"type": "command",
|
|
29
|
+
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
30
|
+
"command": "node \"${CLAUDE_PROJECT_DIR:-.}/.rulesync/hooks/post-edit.mjs\""
|
|
31
|
+
}
|
|
32
|
+
]
|
|
10
33
|
}
|
|
11
34
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
root: false
|
|
3
|
+
targets:
|
|
4
|
+
- '*'
|
|
5
|
+
globs:
|
|
6
|
+
- 'apps/**'
|
|
7
|
+
- 'packages/**'
|
|
8
|
+
- 'tools/**'
|
|
9
|
+
- '*.config.*'
|
|
10
|
+
- 'package.json'
|
|
11
|
+
description: Engineering code conventions (TS, DB, frontend, testing, git) - the always-on core, with a routing table to full detail in docs/standards/.
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Engineering conventions
|
|
15
|
+
|
|
16
|
+
The always-on core of the code standard: what you must obey while typing. Detail, examples, and
|
|
17
|
+
rationale live in `docs/standards/` - read the one file that matches the change instead of
|
|
18
|
+
carrying all of it. The enforced import graph lives in `oss-boundaries`; Playwright rules in
|
|
19
|
+
`e2e-conventions`; the always-on UI and SQL cores in `frontend-conventions` and `db-conventions`.
|
|
20
|
+
|
|
21
|
+
| Change you are making | Read first |
|
|
22
|
+
| ------------------------------------- | ------------------------------------ |
|
|
23
|
+
| schema, type, enum-like value set | `docs/standards/types.md` |
|
|
24
|
+
| function, service method, constructor | `docs/standards/functions.md` |
|
|
25
|
+
| new package, overlay, module layout | `docs/standards/module-structure.md` |
|
|
26
|
+
| a comment or JSDoc | `docs/standards/comments.md` |
|
|
27
|
+
| error class, catch, money path | `docs/standards/errors.md` |
|
|
28
|
+
| a test | `docs/standards/testing.md` |
|
|
29
|
+
| commit, PR | `docs/standards/git-delivery.md` |
|
|
30
|
+
| a failing gate, a new lint rule | `docs/standards/enforcement.md` |
|
|
31
|
+
| React/UI component, page, styling | `docs/standards/frontend.md` |
|
|
32
|
+
| SQL, Drizzle, migration, seed | `docs/standards/database.md` |
|
|
33
|
+
|
|
34
|
+
`docs/standards/frontend.md` doesn't apply to a headless/api-only repo - delete it (and its row
|
|
35
|
+
above) if this repo has no UI apps.
|
|
36
|
+
|
|
37
|
+
## Philosophy
|
|
38
|
+
|
|
39
|
+
- Functional and declarative: pure functions, immutable data, composition. No stateful classes, no
|
|
40
|
+
imperative accumulation loops.
|
|
41
|
+
- Explicit over magic: no decorators, no auto-discovery, no reflection. Every wiring point is a
|
|
42
|
+
greppable typed call (`ctx.provide(TOKEN, factory)`).
|
|
43
|
+
- Self-documenting: clear names beat comments (`percentChange`, not `d` + a comment).
|
|
44
|
+
- YAGNI then DRY: don't build for imagined futures; abstract on the third occurrence, not the
|
|
45
|
+
first.
|
|
46
|
+
- Boring and consistent: match the surrounding code's idiom, naming, and density.
|
|
47
|
+
|
|
48
|
+
## Never (lint-enforced unless noted)
|
|
49
|
+
|
|
50
|
+
- `any` outside tests, `!` non-null assertions, `as` casts to silence the compiler (`as const` is
|
|
51
|
+
fine). Two sanctioned casts: test doubles through one `mock<T>()` helper (never inline in a
|
|
52
|
+
test), and a third-party inference boundary a library gives no honest way to satisfy (one cast,
|
|
53
|
+
one-line `// Library boundary:` note).
|
|
54
|
+
- `interface`, TS `enum`, decorators, inheritance for reuse, default exports (exceptions:
|
|
55
|
+
`*.config.*`, `plugin.ts`, Next.js App Router files).
|
|
56
|
+
- Hand-written duplicates of an inferrable type, re-inferring an imported schema, re-typing
|
|
57
|
+
derived schema fields, ad-hoc/duplicated Zod schemas outside a module's `contract/`.
|
|
58
|
+
- Re-exporting types "to be nice" - import from where defined.
|
|
59
|
+
- Inline `fetch`/`axios` in module code - third-party access is a port + adapter bound at the
|
|
60
|
+
composition root.
|
|
61
|
+
- Deep (`../../`+) relative imports that leave your module/zone, imports of another module's
|
|
62
|
+
internals, import cycles, deep `dist/`/`src/` paths into another package.
|
|
63
|
+
- SQL anti-patterns (bare `timestamp()`, CamelCase identifiers, hand-edited migrations) - detail
|
|
64
|
+
in `docs/standards/database.md`.
|
|
65
|
+
- Silent catches - log with context and rethrow. `ORPCError.message` rendered to a player - UI copy
|
|
66
|
+
keys off `.code` + typed `.data` through `t()`.
|
|
67
|
+
- Comments, unless one states a fact the code cannot contain (an external system's behaviour, a
|
|
68
|
+
spec constraint); a reason goes in the commit or PR, never inline. Never in tests. Detail:
|
|
69
|
+
`docs/standards/comments.md`.
|
|
70
|
+
- An in-process test that mocks the database, a repository, or a sibling service - found one in
|
|
71
|
+
the diff, delete it and replace it with the API E2E; never add to it.
|
|
72
|
+
|
|
73
|
+
## Always
|
|
74
|
+
|
|
75
|
+
- **Naming:** files `kebab-case.ts`, one concept per file, filename names the concept
|
|
76
|
+
(`wallet.service.ts`, never `helpers.ts`); types `PascalCase`; values/functions `camelCase`;
|
|
77
|
+
true global constants `SCREAMING_SNAKE_CASE`; Zod schemas `<Name>Schema` with inferred type
|
|
78
|
+
`<Name>`; booleans read as predicates (`isActive`, `canEdit`); money is a decimal string plus a
|
|
79
|
+
`currency` field alongside it, never `amountCents`; IO functions are verbs (`fetchInvoice`); names
|
|
80
|
+
carry units and intent (`delayMs`, `maxRetries`).
|
|
81
|
+
- **One source of truth per shape** - infer, never hand-write: `z.infer<typeof XSchema>`,
|
|
82
|
+
`typeof x.$inferSelect`. Full detail: `docs/standards/types.md`.
|
|
83
|
+
- **Literal config arrays/objects (option lists, key sets) use `as const`**, not an explicit union
|
|
84
|
+
type annotation - let TypeScript infer the literal types.
|
|
85
|
+
- **Schema-first at every boundary** (HTTP, config, env, events); validate once at the edge, trust
|
|
86
|
+
the type after.
|
|
87
|
+
- **Entity ids typed through their owning type** (`playerId: Player['id']`), never a bare
|
|
88
|
+
`string`.
|
|
89
|
+
- **Guard clauses first, main path last; more than 3 params -> one named object** (a leading
|
|
90
|
+
`tx` handle may stay positional). Always brace control statements, even one-liners (lint:
|
|
91
|
+
`curly`). Detail: `docs/standards/functions.md`.
|
|
92
|
+
- **Construct objects by spread + override**, never a hand-copied field list.
|
|
93
|
+
- **Side effects at the edges**; money paths are transactional AND idempotent (a DB guard inside
|
|
94
|
+
the transaction, not just an idempotency key). Detail: `docs/standards/errors.md`.
|
|
95
|
+
- **Typed, named error classes** from the shared factories, mapped to transport in the router's
|
|
96
|
+
`mapErrors`.
|
|
97
|
+
- **Pin exact dependency versions** (no `^`/`~`); add a dependency deliberately - std lib or a few
|
|
98
|
+
lines often beat a tree.
|
|
99
|
+
- **A feature or fix PR ships its own tests** - no separate test PR. It ships the change, the
|
|
100
|
+
manual verification pass that proves it (drive the running stack by hand, attach a short video
|
|
101
|
+
per acceptance criterion, or the request/response trace for an API-only change, to the PR
|
|
102
|
+
description), and the tests that pass exercised, listed in the description's "Tests" list.
|
|
103
|
+
Which tier each one belongs to, and how to run the manual pass: `docs/standards/testing.md`.
|
|
104
|
+
- **Green before review:** `/check` (typecheck + lint + unit tests) while iterating, `pnpm verify`
|
|
105
|
+
on the committed tree before the PR (the `guard-mr` hook refuses to open one until it passed on
|
|
106
|
+
HEAD). Conventional commits, lowercase subject (PR
|
|
107
|
+
title too - a squash merge turns it into the commit), one PR per concern. Never push without
|
|
108
|
+
explicit confirmation. The description carries what / why / acceptance criteria / bare ticket
|
|
109
|
+
key, the manual-verification evidence (a short video per acceptance criterion, before and after on
|
|
110
|
+
a fix) and the "Tests" list - but no CI checklist the pipeline already shows, no URLs,
|
|
111
|
+
hostnames, secrets, or PII. Detail: `docs/standards/git-delivery.md`.
|
|
112
|
+
- **Fix the import, never work around a lint or boundary violation.** Agent rules are generated
|
|
113
|
+
from `.rulesync/` via `pnpm gen:agents` - never hand-edit a generated file. Detail:
|
|
114
|
+
`docs/standards/enforcement.md`.
|
|
@@ -4,80 +4,14 @@ targets:
|
|
|
4
4
|
- '*'
|
|
5
5
|
globs:
|
|
6
6
|
- 'apps/api/**'
|
|
7
|
-
description: SQL / Drizzle conventions for tables an overlay or local add-on owns.
|
|
7
|
+
description: SQL / Drizzle conventions for tables an overlay or local add-on owns - the always-on core; full detail in docs/standards/database.md.
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Database conventions (SQL / Drizzle)
|
|
11
11
|
|
|
12
|
-
Applies to every table an overlay or local add-on owns (`apps/api/src/extensions/<name>/src/schema/`).
|
|
13
|
-
Tables live in `@openora/*` core for platform domains - never edit those; these rules govern the
|
|
14
|
-
tables you add. Boundary/import rules live in `oss-boundaries`; this file is SQL only.
|
|
12
|
+
Applies to every table an overlay or local add-on owns (`apps/api/src/extensions/<name>/src/schema/`). Platform-domain tables live in `@openora/*` core - never edit those. Identifiers, timestamps, keys, indexes, N+1, idempotency and migrations are specified in `docs/standards/database.md` - read it before touching a schema, query, migration, or seed. The rules below are the ones that file does not state.
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
```ts
|
|
22
|
-
// good - key derives the column; const camelCase; explicit snake_case only for table + index
|
|
23
|
-
export const playerNote = pgTable(
|
|
24
|
-
'player_note',
|
|
25
|
-
{
|
|
26
|
-
id: uuid().primaryKey().defaultRandom(),
|
|
27
|
-
playerId: uuid().notNull(), // -> player_id
|
|
28
|
-
createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
|
|
29
|
-
},
|
|
30
|
-
(t) => [index('player_note_player_id_idx').on(t.playerId)],
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
// bad - explicit/camelCase column names, PascalCase table
|
|
34
|
-
pgTable('PlayerNote', { player_id: uuid('playerId') });
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Timestamps - always timestamptz
|
|
38
|
-
|
|
39
|
-
```ts
|
|
40
|
-
createdAt: timestamp({ withTimezone: true }); // good - Postgres timestamptz, store UTC
|
|
41
|
-
createdAt: timestamp(); // bad - naive, drops the zone
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Every datetime column (`createdAt`, `updatedAt`, `expiresAt`, any `*At`) carries the zone.
|
|
45
|
-
|
|
46
|
-
## Keys, references, indexes
|
|
47
|
-
|
|
48
|
-
- UUID primary keys (`uuid().primaryKey().defaultRandom()`).
|
|
49
|
-
- **No foreign keys across a module/overlay boundary** - store a plain ID string and resolve via the
|
|
50
|
-
oRPC client or a schema subpath. FKs only within the same add-on.
|
|
51
|
-
- `NOT NULL` by default; push defaults to the DB (`.notNull().default(...)`), not app code.
|
|
52
|
-
- Index every column you filter or join on; name it `<table>_<cols>_idx`.
|
|
53
|
-
|
|
54
|
-
## Efficient operations
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
// bad - N+1
|
|
58
|
-
for (const id of ids) await db.select().from(wallet).where(eq(wallet.playerId, id));
|
|
59
|
-
// good - one batched query
|
|
60
|
-
await db.select().from(wallet).where(inArray(wallet.playerId, ids));
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
- Select only the columns you use; don't `select(*)` wide rows to read one field.
|
|
64
|
-
- **Build rows by spread + override, never hand-copy field-by-field.** When a row mostly mirrors a
|
|
65
|
-
validated input, spread it and set only the server-computed fields - `db.insert(x).values({ ...input,
|
|
66
|
-
id, createdAt })` - never re-list `field: input.field` per key (it silently drifts the moment a
|
|
67
|
-
column is added). Keep fields explicit only for: an order-sensitive hash/signature payload; a source
|
|
68
|
-
carrying columns the row must not receive (spread then omit them); or a null-vs-undefined boundary
|
|
69
|
-
that won't coerce (`actorId: input.actorId ?? null`).
|
|
70
|
-
- **Money / critical paths are transactional and idempotent** - a DB guard inside the transaction,
|
|
71
|
-
not just an `idempotencyKey` (delivery is at-least-once).
|
|
72
|
-
|
|
73
|
-
```ts
|
|
74
|
-
await db.transaction(async (t) => {
|
|
75
|
-
if (await ledgerExists(t, idempotencyKey)) return; // guard, not just a key
|
|
76
|
-
await insertLedger(t, { idempotencyKey, amountCents });
|
|
77
|
-
});
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
## Migrations
|
|
81
|
-
|
|
82
|
-
- Never hand-edit generated migrations. Change the `pgTable`, then `pnpm db:migrate`.
|
|
83
|
-
- One migration per schema change; review the generated SQL before committing.
|
|
14
|
+
- Money: exact decimal, never float, never a scaled integer. Every money column is `decimal()` (Postgres `NUMERIC`) - never `real`/`float`, never an `integer` "cents" column. Pair it with a `currency` column; on the wire use `MoneyAmountSchema` (decimal string) + `currency`, never `z.number()`. Balance math runs in SQL, never JS float arithmetic.
|
|
15
|
+
- `pgEnum` derives from a values + schema + type triple declared once (never an inline value array): `X_STATUSES = [...] as const` -> `XStatusSchema = z.enum(X_STATUSES)` -> `pgEnum('x_status', X_STATUSES)`.
|
|
16
|
+
- Bound the fan-out: never `Promise.all(rows.map(fn))` when `rows` is a query result and `fn` calls out per row (a vendor adapter, another service) - it opens unbounded concurrent connections/requests and starves everything else at scale. Use `mapConcurrent(items, limit, fn)` (`@openora/core/server`).
|
|
17
|
+
- Audit every mutation: each state-changing action emits a domain event the `audit` add-on subscribes to, or resolves `AUDIT_WRITER` and calls `record(...)`. A mutation with no audit trail is not done.
|