@orkestrel/scaffold 0.0.2 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -15
- package/dist/bin/scaffold.js +1162 -1200
- package/dist/bin/scaffold.js.map +1 -1
- package/dist/host/AGENTS.md +124 -925
- package/dist/host/CLAUDE.md +260 -495
- package/dist/host/agents/skills/orkestrel-align-packages/SKILL.md +53 -0
- package/dist/host/agents/skills/orkestrel-align-packages/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-align-packages/references/fleet.md +50 -0
- package/dist/host/agents/skills/orkestrel-align-packages/references/integration.md +56 -0
- package/dist/host/agents/skills/orkestrel-build-application/SKILL.md +64 -0
- package/dist/host/agents/skills/orkestrel-build-application/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-build-application/references/application.md +129 -0
- package/dist/host/agents/skills/orkestrel-harden-package/SKILL.md +64 -0
- package/dist/host/agents/skills/orkestrel-harden-package/agents/openai.yaml +4 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/centralization.md +85 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/contract.md +57 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/hardening.md +93 -0
- package/dist/host/agents/skills/orkestrel-harden-package/references/research.md +61 -0
- package/dist/host/claude/agents/application.md +32 -0
- package/dist/host/claude/agents/builder.md +12 -7
- package/dist/host/claude/agents/checker.md +15 -8
- package/dist/host/claude/agents/codex.md +40 -0
- package/dist/host/claude/agents/grok.md +20 -37
- package/dist/host/claude/agents/orkestrel.md +89 -223
- package/dist/host/claude/agents/planner.md +14 -34
- package/dist/host/claude/agents/reviewer.md +39 -24
- package/dist/host/claude/agents/verifier.md +15 -10
- package/dist/host/claude/rules/application.md +57 -0
- package/dist/host/claude/rules/architecture.md +179 -0
- package/dist/host/claude/rules/browser.md +28 -0
- package/dist/host/claude/rules/documentation.md +64 -0
- package/dist/host/claude/rules/names.md +209 -0
- package/dist/host/claude/rules/patterns.md +130 -0
- package/dist/host/claude/rules/quality.md +45 -0
- package/dist/host/claude/rules/styles.md +64 -0
- package/dist/host/claude/rules/tests.md +123 -0
- package/dist/host/claude/rules/typescript.md +78 -0
- package/dist/host/claude/rules/workspace.md +180 -0
- package/dist/host/claude/settings.json +116 -0
- package/dist/host/claude/skills/orkestrel-align-packages/SKILL.md +10 -0
- package/dist/host/claude/skills/orkestrel-build-application/SKILL.md +12 -0
- package/dist/host/claude/skills/orkestrel-harden-package/SKILL.md +10 -0
- package/dist/host/codex/agents/analyst.toml +15 -0
- package/dist/host/codex/agents/application.toml +25 -0
- package/dist/host/codex/agents/builder.toml +24 -0
- package/dist/host/codex/agents/checker.toml +15 -0
- package/dist/host/codex/agents/grok.toml +17 -0
- package/dist/host/codex/agents/implementer.toml +17 -0
- package/dist/host/codex/agents/orkestrel.toml +16 -0
- package/dist/host/codex/agents/planner.toml +20 -0
- package/dist/host/codex/agents/reviewer.toml +18 -0
- package/dist/host/codex/agents/verifier.toml +14 -0
- package/dist/host/codex/config.toml +64 -0
- package/dist/host/dotfiles/oxlintrc.json +307 -1
- package/dist/host/guides/src/scaffold.md +2059 -2007
- package/dist/host/manifest.json +355 -137
- package/dist/host/scripts/codex.sh +49 -0
- package/dist/host/scripts/cursor.sh +33 -47
- package/dist/host/scripts/deps.sh +34 -16
- package/dist/host/scripts/ollama.sh +6 -149
- package/dist/host/tests/setupPolicy.ts +354 -0
- package/dist/src/core/index.cjs +6600 -1199
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +801 -266
- package/dist/src/core/index.d.ts +801 -266
- package/dist/src/core/index.js +6518 -1191
- package/dist/src/core/index.js.map +1 -1
- package/dist/src/server/index.cjs +3132 -593
- package/dist/src/server/index.cjs.map +1 -1
- package/dist/src/server/index.d.cts +643 -95
- package/dist/src/server/index.d.ts +643 -95
- package/dist/src/server/index.js +3054 -594
- package/dist/src/server/index.js.map +1 -1
- package/package.json +15 -12
- package/dist/host/claude/agents/composer.md +0 -64
- package/dist/host/claude/agents/researcher.md +0 -38
- package/dist/host/claude/agents/scout.md +0 -35
- package/dist/host/github/workflows/ci.yml +0 -64
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# ============================================================================
|
|
3
|
-
# scripts/deps.sh — SessionStart hook: project dependencies
|
|
3
|
+
# scripts/deps.sh — SessionStart hook: reproducible project dependencies
|
|
4
4
|
# ----------------------------------------------------------------------------
|
|
5
|
-
# Runs in
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# ONE meaningful line and sends the npm noise to /tmp/deps.log.
|
|
5
|
+
# Runs only in Claude Code's remote environment. The lockfile digest marker
|
|
6
|
+
# prevents a resumed session from treating stale node_modules as a valid
|
|
7
|
+
# installation after package-lock.json changes.
|
|
9
8
|
# ============================================================================
|
|
10
9
|
|
|
11
10
|
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
|
@@ -17,22 +16,41 @@ cd "$CLAUDE_PROJECT_DIR" || exit 0
|
|
|
17
16
|
if [ ! -f package.json ]; then
|
|
18
17
|
exit 0
|
|
19
18
|
fi
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
|
|
20
|
+
LOCK_MARKER="node_modules/.orkestrel-lock.sha256"
|
|
21
|
+
DEPS_LOG="$(mktemp /tmp/orkestrel-deps.XXXXXX)" || {
|
|
22
|
+
echo "deps.sh: could not create a private dependency log — skipped."
|
|
22
23
|
exit 0
|
|
23
|
-
|
|
24
|
+
}
|
|
25
|
+
chmod 600 "$DEPS_LOG"
|
|
26
|
+
|
|
27
|
+
cleanup_deps_log() {
|
|
28
|
+
rm -f -- "$DEPS_LOG"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
trap cleanup_deps_log EXIT
|
|
24
32
|
|
|
25
33
|
if [ -f package-lock.json ]; then
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
LOCK_HASH="$(node -e "process.stdout.write(require('node:crypto').createHash('sha256').update(require('node:fs').readFileSync('package-lock.json')).digest('hex'))" 2>"$DEPS_LOG")"
|
|
35
|
+
|
|
36
|
+
if [ -n "$LOCK_HASH" ] &&
|
|
37
|
+
[ -d node_modules ] &&
|
|
38
|
+
[ -f "$LOCK_MARKER" ] &&
|
|
39
|
+
[ "$(tr -d '\r\n' <"$LOCK_MARKER")" = "$LOCK_HASH" ]; then
|
|
40
|
+
echo "deps.sh: dependencies match package-lock.json — skipped."
|
|
41
|
+
exit 0
|
|
30
42
|
fi
|
|
31
|
-
|
|
32
|
-
if npm
|
|
33
|
-
|
|
43
|
+
|
|
44
|
+
if npm ci --ignore-scripts >"$DEPS_LOG" 2>&1; then
|
|
45
|
+
if [ -n "$LOCK_HASH" ]; then
|
|
46
|
+
printf '%s\n' "$LOCK_HASH" >"$LOCK_MARKER"
|
|
47
|
+
fi
|
|
48
|
+
echo "deps.sh: dependencies installed from package-lock.json (npm ci)."
|
|
34
49
|
else
|
|
35
|
-
echo "deps.sh: npm
|
|
50
|
+
echo "deps.sh: npm ci failed; dependency installation remains incomplete."
|
|
36
51
|
fi
|
|
52
|
+
else
|
|
53
|
+
echo "deps.sh: no package-lock.json — automatic installation refused; commit a lockfile."
|
|
37
54
|
fi
|
|
55
|
+
|
|
38
56
|
exit 0
|
|
@@ -1,163 +1,20 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
# ----------------------------------------------------------------------------
|
|
5
|
-
# One job: bring the local-model daemon up for this session, healthy.
|
|
6
|
-
# Project dependencies live in scripts/deps.sh; the Cursor bench check lives in
|
|
7
|
-
# scripts/cursor.sh. All three are registered in .claude/settings.json and run
|
|
8
|
-
# IN PARALLEL at session start, so the daemon boots while npm installs.
|
|
9
|
-
#
|
|
10
|
-
# 0. Guards -- the orchestration set (hooks included) is mirrored
|
|
11
|
-
# byte-identical across every @orkestrel repo, and this repo serves
|
|
12
|
-
# several cloud environments: exit quietly in any repo that isn't
|
|
13
|
-
# @orkestrel/ollama, and wherever the ollama binary isn't installed.
|
|
14
|
-
# 1. Starts `ollama serve` -- the environment cache snapshots FILES, not
|
|
15
|
-
# running processes, so the daemon must be restarted every session.
|
|
16
|
-
# 2. Self-heals a known container defect -- this container class advertises
|
|
17
|
-
# AMX CPU flags but blocks AMX tile-state permissions at the kernel /
|
|
18
|
-
# seccomp level. Ollama's dynamic CPU-backend loader picks its only
|
|
19
|
-
# AMX-capable variant (libggml-cpu-sapphirerapids.so) on such hosts, and
|
|
20
|
-
# llama-server then segfaults on every model load. A cheap chat probe
|
|
21
|
-
# after the daemon comes up doubles as session warmup AND a canary for
|
|
22
|
-
# this failure; if it fires, we disable that .so and restart the daemon
|
|
23
|
-
# so the loader falls back to the next-best AVX-512 variant.
|
|
24
|
-
#
|
|
25
|
-
# SessionStart stdout is injected into Claude's context: keep stdout to terse
|
|
26
|
-
# status lines (they tell Claude what this environment offers); diagnostics go
|
|
27
|
-
# to stderr / /tmp/ollama.log. No `set -e`: a self-heal misfire should not
|
|
28
|
-
# stop Ollama from coming up.
|
|
29
|
-
# ============================================================================
|
|
2
|
+
# SessionStart loopback capability probe. Lifecycle stays user-owned and no
|
|
3
|
+
# caller-controlled endpoint is contacted or copied into model context.
|
|
30
4
|
|
|
31
|
-
# Run only in cloud sessions. Locally you manage your own Ollama, so skip.
|
|
32
5
|
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
|
33
6
|
exit 0
|
|
34
7
|
fi
|
|
35
8
|
|
|
36
|
-
# --- 0. Guards ---------------------------------------------------------------
|
|
37
|
-
# The daemon only serves the ollama package; every other @orkestrel repo
|
|
38
|
-
# carries this script as part of the mirrored hook set and must skip silently.
|
|
39
|
-
PKG_JSON="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}/package.json"
|
|
40
|
-
if ! grep -Eq '"name"[[:space:]]*:[[:space:]]*"@orkestrel/ollama"' "$PKG_JSON" 2>/dev/null; then
|
|
41
|
-
exit 0
|
|
42
|
-
fi
|
|
43
9
|
if ! command -v ollama >/dev/null 2>&1; then
|
|
44
|
-
echo "ollama.sh:
|
|
45
|
-
exit 0
|
|
46
|
-
fi
|
|
47
|
-
|
|
48
|
-
# Match the locations used by the setup script / environment variables.
|
|
49
|
-
export OLLAMA_MODELS="${OLLAMA_MODELS:-/opt/ollama/models}"
|
|
50
|
-
export OLLAMA_HOST="${OLLAMA_HOST:-127.0.0.1:11434}"
|
|
51
|
-
|
|
52
|
-
# Your Copilot workflow set OLLAMA_ORIGINS="*" so browser page contexts could
|
|
53
|
-
# call the API cross-origin. That was mainly a Playwright concern, so it is off
|
|
54
|
-
# here. Uncomment if anything still hits Ollama from a browser:
|
|
55
|
-
# export OLLAMA_ORIGINS="*"
|
|
56
|
-
|
|
57
|
-
# --- 1. Start the daemon ----------------------------------------------------
|
|
58
|
-
if ! curl -sf "http://${OLLAMA_HOST}/api/tags" >/dev/null 2>&1; then
|
|
59
|
-
nohup ollama serve >/tmp/ollama.log 2>&1 &
|
|
60
|
-
fi
|
|
61
|
-
|
|
62
|
-
ollama_ready=0
|
|
63
|
-
for i in $(seq 1 30); do
|
|
64
|
-
if curl -sf "http://${OLLAMA_HOST}/api/tags" >/dev/null 2>&1; then
|
|
65
|
-
echo "Ollama ready on ${OLLAMA_HOST}"
|
|
66
|
-
ollama_ready=1
|
|
67
|
-
break
|
|
68
|
-
fi
|
|
69
|
-
sleep 1
|
|
70
|
-
done
|
|
71
|
-
|
|
72
|
-
if [ "$ollama_ready" != "1" ]; then
|
|
73
|
-
# Don't fail the session if the daemon is slow; Claude can retry mid-session.
|
|
74
|
-
echo "Warning: Ollama did not report ready within 30s (see /tmp/ollama.log)" >&2
|
|
10
|
+
echo "ollama.sh: loopback capability unavailable."
|
|
75
11
|
exit 0
|
|
76
12
|
fi
|
|
77
13
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# installed both warms that model for the session AND tells us whether this
|
|
81
|
-
# container hit the AMX-tile-permission wall. If it did, disable the
|
|
82
|
-
# AMX-only CPU backend variant and restart the daemon so the loader falls
|
|
83
|
-
# back to AVX-512, then re-probe once to confirm the fix landed.
|
|
84
|
-
sapphirerapids_so="/usr/local/lib/ollama/libggml-cpu-sapphirerapids.so"
|
|
85
|
-
|
|
86
|
-
probe_model() {
|
|
87
|
-
curl -sf "http://${OLLAMA_HOST}/api/tags" 2>/dev/null \
|
|
88
|
-
| grep -o '"name":"[^"]*"' \
|
|
89
|
-
| head -n 1 \
|
|
90
|
-
| sed -e 's/"name":"//' -e 's/"$//'
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
probe_chat() {
|
|
94
|
-
model="$1"
|
|
95
|
-
curl -s --max-time 120 "http://${OLLAMA_HOST}/api/chat" \
|
|
96
|
-
-d "{\"model\":\"${model}\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"stream\":false,\"think\":false,\"keep_alive\":\"30m\",\"options\":{\"num_predict\":1}}"
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
model="$(probe_model)"
|
|
100
|
-
if [ -z "$model" ]; then
|
|
101
|
-
# No model installed yet -- nothing to warm or probe against.
|
|
102
|
-
echo "ollama.sh: no installed model found, skipping warmup probe." >&2
|
|
103
|
-
exit 0
|
|
104
|
-
fi
|
|
105
|
-
|
|
106
|
-
probe_response="$(probe_chat "$model")"
|
|
107
|
-
|
|
108
|
-
if echo "$probe_response" | grep -qi 'segmentation fault\|llama-server process has terminated'; then
|
|
109
|
-
echo "ollama.sh: detected llama-server crash on model warmup (likely the AMX/XTILE-blocked-container defect)." >&2
|
|
110
|
-
if [ -f "$sapphirerapids_so" ]; then
|
|
111
|
-
echo "ollama.sh: disabling AMX-only CPU backend variant so the loader falls back to AVX-512." >&2
|
|
112
|
-
if mv "$sapphirerapids_so" "${sapphirerapids_so}.disabled" 2>/dev/null; then
|
|
113
|
-
echo "ollama.sh: restarting Ollama daemon to pick up the backend change." >&2
|
|
114
|
-
ollama_pid="$(pgrep -x ollama | head -n 1)"
|
|
115
|
-
if [ -n "$ollama_pid" ]; then
|
|
116
|
-
kill "$ollama_pid" 2>/dev/null
|
|
117
|
-
# Wait for the old process to actually exit before relaunching, so the
|
|
118
|
-
# new daemon can't race the dying one for the port and the readiness
|
|
119
|
-
# poll below can't be satisfied by the dying daemon.
|
|
120
|
-
for i in $(seq 1 10); do
|
|
121
|
-
if ! kill -0 "$ollama_pid" 2>/dev/null; then
|
|
122
|
-
break
|
|
123
|
-
fi
|
|
124
|
-
sleep 0.5
|
|
125
|
-
done
|
|
126
|
-
fi
|
|
127
|
-
nohup ollama serve >/tmp/ollama.log 2>&1 &
|
|
128
|
-
|
|
129
|
-
restarted_ready=0
|
|
130
|
-
for i in $(seq 1 30); do
|
|
131
|
-
if curl -sf "http://${OLLAMA_HOST}/api/tags" >/dev/null 2>&1; then
|
|
132
|
-
restarted_ready=1
|
|
133
|
-
break
|
|
134
|
-
fi
|
|
135
|
-
sleep 1
|
|
136
|
-
done
|
|
137
|
-
|
|
138
|
-
if [ "$restarted_ready" = "1" ]; then
|
|
139
|
-
retry_response="$(probe_chat "$model")"
|
|
140
|
-
if echo "$retry_response" | grep -qi 'segmentation fault\|llama-server process has terminated'; then
|
|
141
|
-
echo "ollama.sh: self-heal restart complete, but the retry probe still failed. See /tmp/ollama.log." >&2
|
|
142
|
-
else
|
|
143
|
-
echo "ollama.sh: self-heal succeeded -- model warm (${model}) after AVX-512 fallback."
|
|
144
|
-
fi
|
|
145
|
-
else
|
|
146
|
-
echo "ollama.sh: daemon did not report ready after self-heal restart (see /tmp/ollama.log)." >&2
|
|
147
|
-
fi
|
|
148
|
-
else
|
|
149
|
-
echo "ollama.sh: warning -- could not rename ${sapphirerapids_so} (permissions?). Leaving daemon as-is." >&2
|
|
150
|
-
fi
|
|
151
|
-
else
|
|
152
|
-
echo "ollama.sh: warning -- AMX-only CPU backend not found at expected path; cannot self-heal automatically." >&2
|
|
153
|
-
fi
|
|
154
|
-
elif echo "$probe_response" | grep -q '"message"\|"content"\|"done":true'; then
|
|
155
|
-
echo "ollama.sh: model warm (${model}) -- warmup probe succeeded."
|
|
14
|
+
if curl -sf --max-time 2 "http://127.0.0.1:11434/api/tags" >/dev/null 2>&1; then
|
|
15
|
+
echo "ollama.sh: loopback capability ready; lifecycle remains user-owned."
|
|
156
16
|
else
|
|
157
|
-
echo "ollama.sh:
|
|
158
|
-
echo "ollama.sh: ${probe_response}" | head -c 500 >&2
|
|
159
|
-
echo "" >&2
|
|
160
|
-
echo "ollama.sh: leaving daemon as-is." >&2
|
|
17
|
+
echo "ollama.sh: loopback capability unreachable; lifecycle remains user-owned."
|
|
161
18
|
fi
|
|
162
19
|
|
|
163
20
|
exit 0
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { globSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { basename, join } from 'node:path'
|
|
3
|
+
import * as ts from 'typescript'
|
|
4
|
+
|
|
5
|
+
/** Centralized source modules whose top-level declarations must all be exported. */
|
|
6
|
+
export const CENTRAL_SOURCE_FILES: readonly string[] = Object.freeze([
|
|
7
|
+
'combinators.ts',
|
|
8
|
+
'cloners.ts',
|
|
9
|
+
'compilers.ts',
|
|
10
|
+
'constants.ts',
|
|
11
|
+
'contracts.ts',
|
|
12
|
+
'errors.ts',
|
|
13
|
+
'factories.ts',
|
|
14
|
+
'helpers.ts',
|
|
15
|
+
'handlers.ts',
|
|
16
|
+
'middlewares.ts',
|
|
17
|
+
'parsers.ts',
|
|
18
|
+
'relations.ts',
|
|
19
|
+
'schemas.ts',
|
|
20
|
+
'seeders.ts',
|
|
21
|
+
'shapers.ts',
|
|
22
|
+
'templates.ts',
|
|
23
|
+
'types.ts',
|
|
24
|
+
'validators.ts',
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
/** Centralized files that own module-scope function declarations. */
|
|
28
|
+
export const FUNCTION_SOURCE_FILES: readonly string[] = Object.freeze([
|
|
29
|
+
'combinators.ts',
|
|
30
|
+
'cloners.ts',
|
|
31
|
+
'compilers.ts',
|
|
32
|
+
'errors.ts',
|
|
33
|
+
'factories.ts',
|
|
34
|
+
'handlers.ts',
|
|
35
|
+
'helpers.ts',
|
|
36
|
+
'middlewares.ts',
|
|
37
|
+
'parsers.ts',
|
|
38
|
+
'relations.ts',
|
|
39
|
+
'schemas.ts',
|
|
40
|
+
'seeders.ts',
|
|
41
|
+
'shapers.ts',
|
|
42
|
+
'validators.ts',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
/** Centralized files that own module-scope data declarations. */
|
|
46
|
+
export const DATA_SOURCE_FILES: readonly string[] = Object.freeze([
|
|
47
|
+
'combinators.ts',
|
|
48
|
+
'constants.ts',
|
|
49
|
+
'contracts.ts',
|
|
50
|
+
'relations.ts',
|
|
51
|
+
'schemas.ts',
|
|
52
|
+
'templates.ts',
|
|
53
|
+
'validators.ts',
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
/** One script block extracted from a Vue SFC by the official compiler. */
|
|
57
|
+
export interface VueScriptBlockInterface {
|
|
58
|
+
readonly content: string
|
|
59
|
+
readonly lang?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An injected official Vue SFC script-block extractor. */
|
|
63
|
+
export interface VueScriptExtractorInterface {
|
|
64
|
+
(path: string, content: string): readonly VueScriptBlockInterface[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Normalize platform separators and duplicate glob segments for stable diagnostics. */
|
|
68
|
+
export function normalizePolicyPath(path: string): string {
|
|
69
|
+
return path.replaceAll('\\', '/').replace(/\/+/gu, '/')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether a declaration carries an explicit export modifier. */
|
|
73
|
+
export function hasExportModifier(node: ts.Node): boolean {
|
|
74
|
+
return (
|
|
75
|
+
ts.canHaveModifiers(node) &&
|
|
76
|
+
ts.getModifiers(node)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ===
|
|
77
|
+
true
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a declaration carries a specified modifier. */
|
|
82
|
+
export function hasModifier(node: ts.Node, modifier: ts.SyntaxKind): boolean {
|
|
83
|
+
return (
|
|
84
|
+
ts.canHaveModifiers(node) &&
|
|
85
|
+
ts.getModifiers(node)?.some((candidate) => candidate.kind === modifier) === true
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a module specifier uses a URL scheme other than Node's builtin namespace. */
|
|
90
|
+
export function isUnsupportedModuleSpecifier(specifier: string): boolean {
|
|
91
|
+
return /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(specifier) && !specifier.startsWith('node:')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether an arrow/function expression is an anonymous callback passed directly as an argument. */
|
|
95
|
+
export function isDirectCallback(node: ts.ArrowFunction | ts.FunctionExpression): boolean {
|
|
96
|
+
const parent = node.parent
|
|
97
|
+
return (
|
|
98
|
+
(ts.isCallExpression(parent) || ts.isNewExpression(parent)) &&
|
|
99
|
+
parent.arguments?.some((argument) => argument === node) === true
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Whether a function expression is assigned by one module-scope variable declaration. */
|
|
104
|
+
export function isModuleFunction(node: ts.ArrowFunction | ts.FunctionExpression): boolean {
|
|
105
|
+
const declaration = node.parent
|
|
106
|
+
const list = declaration.parent
|
|
107
|
+
const statement = list.parent
|
|
108
|
+
return (
|
|
109
|
+
ts.isVariableDeclaration(declaration) &&
|
|
110
|
+
ts.isVariableDeclarationList(list) &&
|
|
111
|
+
ts.isVariableStatement(statement) &&
|
|
112
|
+
ts.isSourceFile(statement.parent)
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Whether a property signature belongs to a centralized interface or type alias contract. */
|
|
117
|
+
export function isContractProperty(node: ts.PropertySignature): boolean {
|
|
118
|
+
let parent: ts.Node = node.parent
|
|
119
|
+
while (ts.isTypeLiteralNode(parent)) parent = parent.parent
|
|
120
|
+
return ts.isInterfaceDeclaration(parent) || ts.isTypeAliasDeclaration(parent)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether the sole production triple-slash reference is the generated browser Vite contract. */
|
|
124
|
+
export function hasAllowedTripleSlashReference(path: string, source: ts.SourceFile): boolean {
|
|
125
|
+
return (
|
|
126
|
+
path.replaceAll('\\', '/') === 'app/browser/env.d.ts' &&
|
|
127
|
+
source.referencedFiles.length === 0 &&
|
|
128
|
+
source.libReferenceDirectives.length === 0 &&
|
|
129
|
+
source.typeReferenceDirectives.length === 1 &&
|
|
130
|
+
source.typeReferenceDirectives[0]?.fileName === 'vite/client'
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Inspect a Vue single-file component for syntax that can bypass declared import policy. */
|
|
135
|
+
export function inspectVueCodingLaw(
|
|
136
|
+
path: string,
|
|
137
|
+
scripts: readonly VueScriptBlockInterface[] = [],
|
|
138
|
+
): readonly string[] {
|
|
139
|
+
const violations: string[] = []
|
|
140
|
+
for (const [index, script] of scripts.entries()) {
|
|
141
|
+
if (
|
|
142
|
+
script.lang !== 'ts' &&
|
|
143
|
+
script.lang !== 'tsx' &&
|
|
144
|
+
script.lang !== 'mts' &&
|
|
145
|
+
script.lang !== 'cts'
|
|
146
|
+
) {
|
|
147
|
+
violations.push(`${path}.script-${String(index)} requires a TypeScript script language`)
|
|
148
|
+
}
|
|
149
|
+
const extension =
|
|
150
|
+
script.lang === 'tsx' || script.lang === 'mts' || script.lang === 'cts' ? script.lang : 'ts'
|
|
151
|
+
violations.push(
|
|
152
|
+
...inspectCodingLaw(`${path}.script-${String(index)}.${extension}`, script.content),
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
return violations
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Add syntax-wide coding-law violations while traversing one source tree. */
|
|
159
|
+
export function inspectCodingNode(path: string, node: ts.Node, violations: string[]): void {
|
|
160
|
+
if (
|
|
161
|
+
ts.isAsExpression(node) ||
|
|
162
|
+
ts.isTypeAssertionExpression(node) ||
|
|
163
|
+
ts.isNonNullExpression(node)
|
|
164
|
+
) {
|
|
165
|
+
violations.push(`${path}:${node.getStart()} forbids type/non-null assertions`)
|
|
166
|
+
}
|
|
167
|
+
if (node.kind === ts.SyntaxKind.AnyKeyword) {
|
|
168
|
+
violations.push(`${path}:${node.getStart()} forbids any`)
|
|
169
|
+
}
|
|
170
|
+
if (
|
|
171
|
+
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
172
|
+
node.moduleSpecifier !== undefined &&
|
|
173
|
+
ts.isStringLiteral(node.moduleSpecifier) &&
|
|
174
|
+
isUnsupportedModuleSpecifier(node.moduleSpecifier.text)
|
|
175
|
+
) {
|
|
176
|
+
violations.push(`${path}:${node.getStart()} forbids non-Node URL module specifiers`)
|
|
177
|
+
}
|
|
178
|
+
if (
|
|
179
|
+
ts.isPropertySignature(node) &&
|
|
180
|
+
isContractProperty(node) &&
|
|
181
|
+
!hasModifier(node, ts.SyntaxKind.ReadonlyKeyword)
|
|
182
|
+
) {
|
|
183
|
+
violations.push(`${path}:${node.getStart()} requires readonly contract properties`)
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
ts.isCallExpression(node) &&
|
|
187
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
188
|
+
(node.arguments.length !== 1 || !node.arguments.every(ts.isStringLiteral))
|
|
189
|
+
) {
|
|
190
|
+
violations.push(
|
|
191
|
+
`${path}:${node.getStart()} requires dynamic imports to use string literals so import policy remains enforceable`,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
if (
|
|
195
|
+
ts.isCallExpression(node) &&
|
|
196
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
197
|
+
node.arguments.length === 1 &&
|
|
198
|
+
node.arguments.every(ts.isStringLiteral) &&
|
|
199
|
+
isUnsupportedModuleSpecifier(node.arguments[0]?.text ?? '')
|
|
200
|
+
) {
|
|
201
|
+
violations.push(`${path}:${node.getStart()} forbids non-Node URL module specifiers`)
|
|
202
|
+
}
|
|
203
|
+
if (ts.isFunctionDeclaration(node) && !ts.isSourceFile(node.parent)) {
|
|
204
|
+
violations.push(`${path}:${node.getStart()} forbids nested function declarations`)
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
208
|
+
!isDirectCallback(node) &&
|
|
209
|
+
!isModuleFunction(node)
|
|
210
|
+
) {
|
|
211
|
+
violations.push(`${path}:${node.getStart()} forbids hidden function assignments`)
|
|
212
|
+
}
|
|
213
|
+
ts.forEachChild(node, (child) => inspectCodingNode(path, child, violations))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Inspect one TypeScript source module for repository coding-law violations. */
|
|
217
|
+
export function inspectCodingLaw(path: string, content: string): readonly string[] {
|
|
218
|
+
const violations: string[] = []
|
|
219
|
+
const source = ts.createSourceFile(path, content, ts.ScriptTarget.Latest, true)
|
|
220
|
+
const file = basename(path)
|
|
221
|
+
|
|
222
|
+
if (/\.[cm]?jsx?$/u.test(path)) {
|
|
223
|
+
violations.push(`${path} production modules use TypeScript source extensions`)
|
|
224
|
+
}
|
|
225
|
+
if (/@ts-(?:expect-error|ignore|nocheck)|eslint-disable|oxlint-disable/u.test(content)) {
|
|
226
|
+
violations.push(`${path} forbids suppression directives`)
|
|
227
|
+
}
|
|
228
|
+
if (
|
|
229
|
+
(source.referencedFiles.length > 0 ||
|
|
230
|
+
source.libReferenceDirectives.length > 0 ||
|
|
231
|
+
source.typeReferenceDirectives.length > 0) &&
|
|
232
|
+
!hasAllowedTripleSlashReference(path, source)
|
|
233
|
+
) {
|
|
234
|
+
violations.push(`${path} forbids triple-slash references outside app/browser/env.d.ts`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (file === 'index.ts') {
|
|
238
|
+
for (const statement of source.statements) {
|
|
239
|
+
if (
|
|
240
|
+
!ts.isExportDeclaration(statement) ||
|
|
241
|
+
statement.exportClause !== undefined ||
|
|
242
|
+
statement.isTypeOnly ||
|
|
243
|
+
statement.moduleSpecifier === undefined
|
|
244
|
+
) {
|
|
245
|
+
violations.push(`${path} barrels contain only export * declarations`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (const statement of source.statements) {
|
|
251
|
+
if (
|
|
252
|
+
(ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) &&
|
|
253
|
+
file !== 'types.ts'
|
|
254
|
+
) {
|
|
255
|
+
violations.push(`${path} centralizes interfaces and type aliases in types.ts`)
|
|
256
|
+
}
|
|
257
|
+
if (
|
|
258
|
+
CENTRAL_SOURCE_FILES.includes(file) &&
|
|
259
|
+
(ts.isClassDeclaration(statement) ||
|
|
260
|
+
ts.isFunctionDeclaration(statement) ||
|
|
261
|
+
ts.isInterfaceDeclaration(statement) ||
|
|
262
|
+
ts.isTypeAliasDeclaration(statement) ||
|
|
263
|
+
ts.isVariableStatement(statement)) &&
|
|
264
|
+
!hasExportModifier(statement)
|
|
265
|
+
) {
|
|
266
|
+
violations.push(`${path} exports every centralized declaration`)
|
|
267
|
+
}
|
|
268
|
+
if (ts.isFunctionDeclaration(statement) && !FUNCTION_SOURCE_FILES.includes(file)) {
|
|
269
|
+
violations.push(`${path} places module functions in their centralized kind file`)
|
|
270
|
+
}
|
|
271
|
+
if (ts.isVariableStatement(statement) && !DATA_SOURCE_FILES.includes(file)) {
|
|
272
|
+
violations.push(`${path} places module data in its centralized kind file`)
|
|
273
|
+
}
|
|
274
|
+
if (
|
|
275
|
+
ts.isClassDeclaration(statement) &&
|
|
276
|
+
file !== 'errors.ts' &&
|
|
277
|
+
!/^[A-Z][A-Za-z0-9]*\.ts$/u.test(file)
|
|
278
|
+
) {
|
|
279
|
+
violations.push(`${path} places each class in its matching implementation or errors file`)
|
|
280
|
+
}
|
|
281
|
+
if (ts.isEnumDeclaration(statement) && file !== 'types.ts') {
|
|
282
|
+
violations.push(`${path} centralizes enum contracts in types.ts`)
|
|
283
|
+
}
|
|
284
|
+
if (file === 'constants.ts' && ts.isVariableStatement(statement)) {
|
|
285
|
+
if ((statement.declarationList.flags & ts.NodeFlags.Const) === 0) {
|
|
286
|
+
violations.push(`${path} constants.ts permits only const declarations`)
|
|
287
|
+
}
|
|
288
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
289
|
+
if (
|
|
290
|
+
!ts.isIdentifier(declaration.name) ||
|
|
291
|
+
!/^[A-Z][A-Z0-9_]*$/u.test(declaration.name.text)
|
|
292
|
+
) {
|
|
293
|
+
violations.push(`${path}:${declaration.getStart()} requires UPPER_SNAKE_CASE constants`)
|
|
294
|
+
}
|
|
295
|
+
if (
|
|
296
|
+
declaration.initializer !== undefined &&
|
|
297
|
+
(ts.isArrayLiteralExpression(declaration.initializer) ||
|
|
298
|
+
ts.isObjectLiteralExpression(declaration.initializer))
|
|
299
|
+
) {
|
|
300
|
+
violations.push(`${path}:${declaration.getStart()} freezes collection constants`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (/^[A-Z][A-Za-z0-9]*\.ts$/u.test(file)) {
|
|
307
|
+
const classes = source.statements.filter(ts.isClassDeclaration)
|
|
308
|
+
const invalid = source.statements.filter(
|
|
309
|
+
(statement) => !ts.isImportDeclaration(statement) && !ts.isClassDeclaration(statement),
|
|
310
|
+
)
|
|
311
|
+
if (
|
|
312
|
+
classes.length !== 1 ||
|
|
313
|
+
invalid.length !== 0 ||
|
|
314
|
+
classes[0]?.name?.text !== file.slice(0, -3) ||
|
|
315
|
+
classes[0] === undefined ||
|
|
316
|
+
!hasExportModifier(classes[0])
|
|
317
|
+
) {
|
|
318
|
+
violations.push(
|
|
319
|
+
`${path} implementation modules contain imports and one matching exported class`,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
for (const member of classes[0]?.members ?? []) {
|
|
323
|
+
if (hasModifier(member, ts.SyntaxKind.PrivateKeyword)) {
|
|
324
|
+
violations.push(`${path}:${member.getStart()} uses runtime # privacy`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
inspectCodingNode(path, source, violations)
|
|
330
|
+
return violations
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Inspect every production TypeScript module under one workspace. */
|
|
334
|
+
export function inspectCodingWorkspace(
|
|
335
|
+
root: string,
|
|
336
|
+
vueScripts?: VueScriptExtractorInterface,
|
|
337
|
+
): readonly string[] {
|
|
338
|
+
const violations: string[] = []
|
|
339
|
+
for (const path of globSync('{app,src}/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx,vue}', {
|
|
340
|
+
cwd: root,
|
|
341
|
+
})) {
|
|
342
|
+
const content = readFileSync(join(root, path), 'utf8')
|
|
343
|
+
const normalizedPath = normalizePolicyPath(path)
|
|
344
|
+
if (path.endsWith('.vue') && !normalizedPath.startsWith('app/browser/')) {
|
|
345
|
+
violations.push(`${normalizedPath} Vue components belong in app/browser`)
|
|
346
|
+
}
|
|
347
|
+
violations.push(
|
|
348
|
+
...(path.endsWith('.vue')
|
|
349
|
+
? inspectVueCodingLaw(normalizedPath, vueScripts?.(normalizedPath, content))
|
|
350
|
+
: inspectCodingLaw(normalizedPath, content)),
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
return violations
|
|
354
|
+
}
|