@cosmicdrift/kumiko-guards 0.297.0 → 0.299.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/README.md +19 -0
- package/hooks/pre-push +36 -0
- package/package.json +5 -3
- package/src/_lib/guard-kit.ts +5 -2
- package/src/changes.json +17 -0
- package/src/check-complexity.ts +27 -10
- package/src/guard-access-denied-test.ts +9 -10
- package/src/guard-direct-fetch.ts +2 -3
- package/src/guard-i18n-locale-mount.ts +5 -4
- package/src/guard-i18n-locale-terminology.ts +4 -5
- package/src/guard-no-direct-fs.ts +2 -3
- package/src/guard-pii-annotations.ts +24 -13
- package/src/guard-restricted-symbols.ts +1 -2
- package/src/guard-section-fields-raw.ts +28 -13
- package/src/guard-test-timeouts.ts +30 -14
- package/src/guard-text-field-stance.ts +24 -13
- package/src/guard-write-handler-qns.ts +1 -2
- package/src/pre-push.sh +92 -0
- package/src/run-guards.ts +35 -4
package/README.md
CHANGED
|
@@ -14,3 +14,22 @@ The baseline lives at `.kumiko-security-baseline.json` in the consumer
|
|
|
14
14
|
repo's root. `--explain` prints the resolved repo and per-guard scan scope
|
|
15
15
|
without running any guard; `--write-security-baseline` freezes current
|
|
16
16
|
findings into the baseline file.
|
|
17
|
+
|
|
18
|
+
## `kumiko-pre-push`
|
|
19
|
+
|
|
20
|
+
A POSIX-sh pre-push hook, wired by copying the package's `hooks/pre-push`
|
|
21
|
+
shim to `.husky/pre-push`. The shim execs the nearest executable
|
|
22
|
+
`node_modules/.bin/kumiko-pre-push` from the repo root upwards (so worktrees
|
|
23
|
+
without their own `node_modules` resolve via the parent install) and fails
|
|
24
|
+
closed with a `bun install` / `PRE_PUSH_SKIP=1` hint when none exists. Inside
|
|
25
|
+
the cosmicdriftgamestudio parent workspace it runs a scoped `bun check` for
|
|
26
|
+
the pushing repo only; in a standalone clone it runs the repo's own
|
|
27
|
+
`package.json` `test` script. Set `PRE_PUSH_SKIP=1` to bypass.
|
|
28
|
+
|
|
29
|
+
Extension point: a tracked, executable `scripts/pre-push-extra.sh` runs
|
|
30
|
+
before the main check in every branch (worktree, parent-scoped, and
|
|
31
|
+
standalone), with `KUMIKO_PUSH_REPO_ROOT` and `KUMIKO_PUSH_PARENT_DIR`
|
|
32
|
+
available inline (never exported, so nothing else inherits them). An
|
|
33
|
+
untracked or non-executable `scripts/pre-push-extra.sh` is never run — this
|
|
34
|
+
is fail-closed so a stray or unreviewed file can't hijack push. A non-zero
|
|
35
|
+
exit from `pre-push-extra.sh` aborts the push before the main check runs.
|
package/hooks/pre-push
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# PRE_PUSH_SKIP is checked here too so the bypass works even without node_modules.
|
|
3
|
+
if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
|
|
4
|
+
echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
|
|
5
|
+
exit 0
|
|
6
|
+
fi
|
|
7
|
+
|
|
8
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
9
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
10
|
+
echo "[pre-push] FATAL: git rev-parse --show-toplevel failed — refusing push" >&2
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
# Walk up to / so a worktree without its own node_modules resolves via the
|
|
15
|
+
# parent install; `[ -x ]` also skips a broken (uninstalled) symlink.
|
|
16
|
+
BIN=""
|
|
17
|
+
DIR="$REPO_ROOT"
|
|
18
|
+
while true; do
|
|
19
|
+
CANDIDATE="$DIR/node_modules/.bin/kumiko-pre-push"
|
|
20
|
+
if [ -x "$CANDIDATE" ]; then
|
|
21
|
+
BIN="$CANDIDATE"
|
|
22
|
+
break
|
|
23
|
+
fi
|
|
24
|
+
if [ "$DIR" = "/" ]; then
|
|
25
|
+
break
|
|
26
|
+
fi
|
|
27
|
+
DIR="$(dirname "$DIR")"
|
|
28
|
+
done
|
|
29
|
+
|
|
30
|
+
if [ -z "$BIN" ]; then
|
|
31
|
+
echo "[pre-push] kumiko-pre-push not found in any node_modules/.bin above $REPO_ROOT." >&2
|
|
32
|
+
echo "Run \`bun install\`, or set PRE_PUSH_SKIP=1 to bypass this hook." >&2
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
cd "$REPO_ROOT" && exec "$BIN" "$@"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.299.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
"bin": {
|
|
27
|
-
"kumiko-guards": "./src/cli.ts"
|
|
27
|
+
"kumiko-guards": "./src/cli.ts",
|
|
28
|
+
"kumiko-pre-push": "./src/pre-push.sh"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.299.0",
|
|
31
32
|
"ts-morph": "^28.0.0"
|
|
32
33
|
},
|
|
33
34
|
"publishConfig": {
|
|
@@ -37,6 +38,7 @@
|
|
|
37
38
|
"files": [
|
|
38
39
|
"src",
|
|
39
40
|
"!src/**/__tests__/**",
|
|
41
|
+
"hooks",
|
|
40
42
|
"README.md",
|
|
41
43
|
"LICENSE"
|
|
42
44
|
]
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -52,7 +52,10 @@ export type AstGuard = {
|
|
|
52
52
|
readonly hint?: string;
|
|
53
53
|
/** Security guards fail on every finding not frozen in the per-repo security baseline — no skip flag. */
|
|
54
54
|
readonly security?: boolean;
|
|
55
|
-
|
|
55
|
+
/** `roots` are the same roots `runGuards` scanned `files` with — a guard classifying paths against repo roots (`relFromRepoRoot`) must use these, not re-derive its own single-repo `resolveRepoRoots()`, or a multi-root caller's roots never reach it. */
|
|
56
|
+
run(files: readonly SourceFile[], roots?: readonly RepoRoot[]): GuardOutcome;
|
|
57
|
+
/** Ratchet guards only: freeze the current findings into that guard's own baseline file. */
|
|
58
|
+
writeBaseline?(files: readonly SourceFile[]): void;
|
|
56
59
|
};
|
|
57
60
|
|
|
58
61
|
export function isSecurityGuard(guard: Pick<AstGuard, "security">): boolean {
|
|
@@ -309,7 +312,7 @@ export function runGuards(
|
|
|
309
312
|
const scans = scan(guard, roots);
|
|
310
313
|
const paths = [...new Set(scans.flatMap((s) => s.files))].sort();
|
|
311
314
|
const files = paths.map((p) => project.getSourceFile(p) ?? project.addSourceFileAtPath(p));
|
|
312
|
-
const outcome = guard.run(files);
|
|
315
|
+
const outcome = guard.run(files, roots);
|
|
313
316
|
// Security guards get no skip flag: every finding must clear the baseline.
|
|
314
317
|
const securityResult = isSecurityGuard(guard)
|
|
315
318
|
? applySecurityBaseline({
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.299.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "kumiko-guards ships a kumiko-pre-push bin carrying the shared pre-push hook mechanics",
|
|
6
|
+
"detail": "Consumer repos can replace their vendored .husky/pre-push with a thin shim; a tracked, executable scripts/pre-push-extra.sh is the per-repo extension point."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.299.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "kumiko-guards ships the canonical `hooks/pre-push` shim that execs the nearest `node_modules/.bin/kumiko-pre-push` and fails closed with a `bun install` / `PRE_PUSH_SKIP=1` hint when it is missing; repos copy it to `.husky/pre-push`."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.299.0",
|
|
15
|
+
"type": "fix",
|
|
16
|
+
"title": "AstGuard.run receives the caller's repo roots (guard-test-timeouts, guard-pii-annotations, guard-section-fields-raw, guard-text-field-stance, guard-i18n-locale-mount, guard-i18n-locale-terminology, check-complexity, guard-direct-fetch, guard-no-direct-fs, guard-write-handler-qns) so multi-repo aggregate runs stop throwing \"cannot classify path\" or dropping sibling-repo findings; --write-baseline now requires --guard=<name>",
|
|
17
|
+
"migration": "No action needed for AstGuard.run's new optional `roots` parameter. `kumiko-guards guards --write-baseline` no longer writes every ratchet guard's baseline in one call; pass `--guard=<name>` to freeze one guard deliberately, or run it once per guard."
|
|
18
|
+
},
|
|
2
19
|
{
|
|
3
20
|
"version": "0.292.0",
|
|
4
21
|
"type": "improvement",
|
package/src/check-complexity.ts
CHANGED
|
@@ -41,10 +41,12 @@ import {
|
|
|
41
41
|
type GuardOutcome,
|
|
42
42
|
type GuardViolation,
|
|
43
43
|
isLocalFinding,
|
|
44
|
+
relFromRepoRoot,
|
|
44
45
|
runStandalone,
|
|
45
46
|
type ScanSpec,
|
|
46
47
|
} from "./_lib/guard-kit";
|
|
47
48
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
49
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
48
50
|
|
|
49
51
|
const ROOT = process.cwd();
|
|
50
52
|
|
|
@@ -153,8 +155,11 @@ export function computeComplexity(fn: Node): number {
|
|
|
153
155
|
return complexity;
|
|
154
156
|
}
|
|
155
157
|
|
|
156
|
-
export function collectHotspots(
|
|
157
|
-
|
|
158
|
+
export function collectHotspots(
|
|
159
|
+
sf: SourceFile,
|
|
160
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
161
|
+
): Hotspot[] {
|
|
162
|
+
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
158
163
|
const hotspots: Hotspot[] = [];
|
|
159
164
|
for (const kind of FUNCTION_KINDS) {
|
|
160
165
|
for (const fn of sf.getDescendantsOfKind(kind)) {
|
|
@@ -192,7 +197,10 @@ export function countHotspotsByFile(hotspots: readonly Hotspot[]): Record<string
|
|
|
192
197
|
return counts;
|
|
193
198
|
}
|
|
194
199
|
|
|
195
|
-
function scan(
|
|
200
|
+
function scan(
|
|
201
|
+
files: readonly SourceFile[],
|
|
202
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
203
|
+
): {
|
|
196
204
|
hotspots: Hotspot[];
|
|
197
205
|
scanned: number;
|
|
198
206
|
} {
|
|
@@ -201,7 +209,7 @@ function scan(files: readonly SourceFile[]): {
|
|
|
201
209
|
for (const sf of files) {
|
|
202
210
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
203
211
|
scanned++;
|
|
204
|
-
hotspots.push(...collectHotspots(sf));
|
|
212
|
+
hotspots.push(...collectHotspots(sf, roots));
|
|
205
213
|
}
|
|
206
214
|
hotspots.sort((a, b) => b.complexity - a.complexity);
|
|
207
215
|
return { hotspots, scanned };
|
|
@@ -245,14 +253,18 @@ function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
|
|
|
245
253
|
countHotspotsByFile(hotspots),
|
|
246
254
|
`Split the function, or allow it with "// ${BUDGET_TAG} <reason>".`,
|
|
247
255
|
{
|
|
248
|
-
formatDriftRemediation: `Run \`
|
|
256
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="Complexity Check"\` once.`,
|
|
249
257
|
resolveLine: (file) => resolveHotspotLine(hotspots, file),
|
|
250
258
|
},
|
|
251
259
|
);
|
|
252
260
|
}
|
|
253
261
|
|
|
254
|
-
function analyse(
|
|
255
|
-
|
|
262
|
+
function analyse(
|
|
263
|
+
files: readonly SourceFile[],
|
|
264
|
+
roots: readonly RepoRoot[],
|
|
265
|
+
compareBaseline: boolean,
|
|
266
|
+
): GuardOutcome {
|
|
267
|
+
const { hotspots, scanned } = scan(files, roots);
|
|
256
268
|
report(hotspots, scanned);
|
|
257
269
|
if (!compareBaseline) {
|
|
258
270
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
@@ -267,8 +279,13 @@ export const guard: AstGuard = {
|
|
|
267
279
|
// Kein Remediation-Text hier: reportResults haengt hint an JEDEN Fail, auch
|
|
268
280
|
// an Format-Drift, wo "Funktion aufteilen" in die Irre fuehrt. Der konkrete
|
|
269
281
|
// Rat steht deshalb in der jeweiligen Violation-Message.
|
|
270
|
-
hint:
|
|
271
|
-
run: (files) => analyse(files, true),
|
|
282
|
+
hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="Complexity Check"`',
|
|
283
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
284
|
+
writeBaseline: (files) => {
|
|
285
|
+
const { hotspots, scanned } = scan(files);
|
|
286
|
+
report(hotspots, scanned);
|
|
287
|
+
complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
|
|
288
|
+
},
|
|
272
289
|
};
|
|
273
290
|
|
|
274
291
|
// Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
|
|
@@ -285,7 +302,7 @@ if (import.meta.main) {
|
|
|
285
302
|
}
|
|
286
303
|
if (args.includes("--no-baseline")) {
|
|
287
304
|
const project = buildSharedProject([guard]);
|
|
288
|
-
analyse(filesForGuard(project, guard), false);
|
|
305
|
+
analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
|
|
289
306
|
process.exit(0);
|
|
290
307
|
}
|
|
291
308
|
runStandalone(guard);
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
mentionsAsWord,
|
|
27
27
|
nameForms,
|
|
28
28
|
} from "./_lib/handler-name-forms";
|
|
29
|
-
import { resolveRepoRoots } from "./_lib/roots";
|
|
29
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
30
30
|
|
|
31
31
|
const ROOT = process.cwd();
|
|
32
32
|
const SCAN: ScanSpec = {
|
|
@@ -118,16 +118,15 @@ export const guard: AstGuard = {
|
|
|
118
118
|
scan: SCAN,
|
|
119
119
|
security: true,
|
|
120
120
|
hint: "Role-restricted write handlers need a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403). Existing gaps are frozen in the security baseline; after closing one: `bun guards/run-guards.ts --write-security-baseline`.",
|
|
121
|
-
run(files) {
|
|
121
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
122
122
|
// consumer CI scans only its own repo, so a sibling repo's test must not count as coverage
|
|
123
|
-
const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}));
|
|
123
|
+
const violations: GuardViolation[] = findHandlersWithoutAccessDeniedTest(files, roots).map(
|
|
124
|
+
(h) => ({
|
|
125
|
+
file: path.relative(ROOT, h.file),
|
|
126
|
+
line: h.line,
|
|
127
|
+
message: `role-restricted write handler "${h.name}" has no access-denied test — add a test that references the handler and asserts a caller without the role is rejected (AccessDeniedError / access_denied / 403).`,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
131
130
|
return { violations };
|
|
132
131
|
},
|
|
133
132
|
};
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
runStandalone,
|
|
30
30
|
type ScanSpec,
|
|
31
31
|
} from "./_lib/guard-kit";
|
|
32
|
-
import { resolveRepoRoots } from "./_lib/roots";
|
|
32
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
33
33
|
|
|
34
34
|
const SCAN: ScanSpec = {
|
|
35
35
|
scope: "source",
|
|
@@ -123,9 +123,8 @@ export const guard: AstGuard = {
|
|
|
123
123
|
'Same-origin path literal (`"/api/..."`) is allowed; otherwise put `// guard-allow: same-origin fetch` on the line above. ' +
|
|
124
124
|
"Local bindings named fetch are flagged (false positive — unblock via that marker or ALLOWLIST in infra/guards/guard-direct-fetch.ts). " +
|
|
125
125
|
'Known gap: bracket access (globalThis["fetch"](...)).',
|
|
126
|
-
run(files) {
|
|
126
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
127
127
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
128
|
-
const roots = resolveRepoRoots();
|
|
129
128
|
|
|
130
129
|
for (const sf of files) {
|
|
131
130
|
const file = sf.getFilePath();
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* bun guards/guard-i18n-locale-mount.ts
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { dirname, join
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
22
|
import {
|
|
23
23
|
type FileSystemHost,
|
|
24
24
|
type Identifier,
|
|
@@ -30,11 +30,12 @@ import {
|
|
|
30
30
|
type AstGuard,
|
|
31
31
|
type GuardViolation,
|
|
32
32
|
isLocalFinding,
|
|
33
|
+
relFromRepoRoot,
|
|
33
34
|
runStandalone,
|
|
34
35
|
type ScanSpec,
|
|
35
36
|
} from "./_lib/guard-kit";
|
|
37
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
36
38
|
|
|
37
|
-
const ROOT = process.cwd();
|
|
38
39
|
const LOCALE_DE_DEP = "@cosmicdrift/kumiko-locale-de";
|
|
39
40
|
|
|
40
41
|
const SCAN: ScanSpec = {
|
|
@@ -306,9 +307,9 @@ export const guard: AstGuard = {
|
|
|
306
307
|
name: "i18n-Locale-Mount Guard",
|
|
307
308
|
scan: SCAN,
|
|
308
309
|
hint: "Mount point (createKumikoApp/createPublicSurface/LocaleProvider) without a German locale feature — add localeDeClient() to clientFeatures, or localeDe() to the server feature list.",
|
|
309
|
-
run(files) {
|
|
310
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
310
311
|
const violations = findViolations(files)
|
|
311
|
-
.map((v) => ({ ...v, file:
|
|
312
|
+
.map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
|
|
312
313
|
.filter(isLocalFinding);
|
|
313
314
|
return { violations };
|
|
314
315
|
},
|
|
@@ -13,18 +13,17 @@
|
|
|
13
13
|
* Ignore: // kumiko-lint-ignore i18n-locale-terminology
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { relative as pathRelative } from "node:path";
|
|
17
16
|
import { type Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
18
17
|
import {
|
|
19
18
|
type AstGuard,
|
|
20
19
|
type GuardViolation,
|
|
21
20
|
isLocalFinding,
|
|
21
|
+
relFromRepoRoot,
|
|
22
22
|
runStandalone,
|
|
23
23
|
type ScanSpec,
|
|
24
24
|
} from "./_lib/guard-kit";
|
|
25
25
|
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
26
|
-
|
|
27
|
-
const ROOT = process.cwd();
|
|
26
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
28
27
|
|
|
29
28
|
const SCAN: ScanSpec = {
|
|
30
29
|
scope: "source",
|
|
@@ -106,9 +105,9 @@ export const guard: AstGuard = {
|
|
|
106
105
|
name: "i18n-Locale-Terminology Guard",
|
|
107
106
|
scan: SCAN,
|
|
108
107
|
hint: "DE: Mandant statt Tenant/Organisation; ES: Organización statt tenant-Loanword. Rollen wie TenantAdmin sind OK.",
|
|
109
|
-
run(files) {
|
|
108
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
110
109
|
const violations = findViolations(files)
|
|
111
|
-
.map((v) => ({ ...v, file:
|
|
110
|
+
.map((v) => ({ ...v, file: relFromRepoRoot(v.file, roots) }))
|
|
112
111
|
.filter(isLocalFinding);
|
|
113
112
|
return { violations };
|
|
114
113
|
},
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
runStandalone,
|
|
35
35
|
type ScanSpec,
|
|
36
36
|
} from "./_lib/guard-kit";
|
|
37
|
-
import { resolveRepoRoots } from "./_lib/roots";
|
|
37
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
38
38
|
|
|
39
39
|
const SCAN: ScanSpec = {
|
|
40
40
|
scope: "source",
|
|
@@ -204,9 +204,8 @@ export const guard: AstGuard = {
|
|
|
204
204
|
scan: SCAN,
|
|
205
205
|
security: true,
|
|
206
206
|
hint: "Direct node:fs import outside the allowlist — use FileStorageProvider (packages/framework/src/files/) instead of wiring fs yourself. The path-traversal guard (resolveContainedPath) only exists there. Legitimate new tooling caller? Extend the allowlist in guard-no-direct-fs.ts, with a reason + repo scope.",
|
|
207
|
-
run(files) {
|
|
207
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
208
208
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
209
|
-
const roots = resolveRepoRoots();
|
|
210
209
|
|
|
211
210
|
for (const sf of files) {
|
|
212
211
|
const file = sf.getFilePath();
|
|
@@ -37,9 +37,11 @@ import {
|
|
|
37
37
|
type GuardOutcome,
|
|
38
38
|
type GuardViolation,
|
|
39
39
|
isLocalFinding,
|
|
40
|
+
relFromRepoRoot,
|
|
40
41
|
runStandalone,
|
|
41
42
|
type ScanSpec,
|
|
42
43
|
} from "./_lib/guard-kit";
|
|
44
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
43
45
|
|
|
44
46
|
const ROOT = process.cwd();
|
|
45
47
|
|
|
@@ -89,8 +91,8 @@ const PII_USER_OWNED_NAME_HINTS = new Set([
|
|
|
89
91
|
"notes",
|
|
90
92
|
]);
|
|
91
93
|
|
|
92
|
-
function relFile(sf: SourceFile): string {
|
|
93
|
-
return
|
|
94
|
+
function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
|
|
95
|
+
return relFromRepoRoot(sf.getFilePath(), roots);
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
|
|
@@ -149,7 +151,7 @@ export interface Finding {
|
|
|
149
151
|
hint: string;
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
function scanFieldFactories(sf: SourceFile): Finding[] {
|
|
154
|
+
function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
|
|
153
155
|
const findings: Finding[] = [];
|
|
154
156
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
155
157
|
const callee = call.getExpression().getText();
|
|
@@ -171,15 +173,19 @@ function scanFieldFactories(sf: SourceFile): Finding[] {
|
|
|
171
173
|
if (!hint) continue;
|
|
172
174
|
|
|
173
175
|
const line = call.getStartLineNumber();
|
|
174
|
-
|
|
176
|
+
const file = relFile(sf, roots);
|
|
177
|
+
findings.push({ file, line, fieldName, hint });
|
|
175
178
|
console.warn(
|
|
176
|
-
` [pii-annotations WARN] ${
|
|
179
|
+
` [pii-annotations WARN] ${file}:${line} field "${fieldName}" looks like PII — mark ${hint}`,
|
|
177
180
|
);
|
|
178
181
|
}
|
|
179
182
|
return findings;
|
|
180
183
|
}
|
|
181
184
|
|
|
182
|
-
function scan(
|
|
185
|
+
function scan(
|
|
186
|
+
files: readonly SourceFile[],
|
|
187
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
188
|
+
): {
|
|
183
189
|
findings: Finding[];
|
|
184
190
|
scanned: number;
|
|
185
191
|
} {
|
|
@@ -188,7 +194,7 @@ function scan(files: readonly SourceFile[]): {
|
|
|
188
194
|
for (const sf of files) {
|
|
189
195
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
190
196
|
scanned++;
|
|
191
|
-
findings.push(...scanFieldFactories(sf));
|
|
197
|
+
findings.push(...scanFieldFactories(sf, roots));
|
|
192
198
|
}
|
|
193
199
|
return { findings, scanned };
|
|
194
200
|
}
|
|
@@ -224,14 +230,18 @@ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
|
|
|
224
230
|
baselineCounts(findings),
|
|
225
231
|
`Annotate the field ({ personal: ... } / { pii: true } / { userOwned: ... } / { tenantOwned: true } / { allowPlaintext: "..." }).`,
|
|
226
232
|
{
|
|
227
|
-
formatDriftRemediation: `Run \`
|
|
233
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"\` once.`,
|
|
228
234
|
resolveLine,
|
|
229
235
|
},
|
|
230
236
|
);
|
|
231
237
|
}
|
|
232
238
|
|
|
233
|
-
function analyse(
|
|
234
|
-
|
|
239
|
+
function analyse(
|
|
240
|
+
files: readonly SourceFile[],
|
|
241
|
+
roots: readonly RepoRoot[],
|
|
242
|
+
compareBaseline: boolean,
|
|
243
|
+
): GuardOutcome {
|
|
244
|
+
const { findings } = scan(files, roots);
|
|
235
245
|
if (!compareBaseline) {
|
|
236
246
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
237
247
|
return { violations: [] };
|
|
@@ -242,8 +252,9 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
|
|
|
242
252
|
export const guard: AstGuard = {
|
|
243
253
|
name: "PII-Annotations Guard",
|
|
244
254
|
scan: SCAN,
|
|
245
|
-
hint:
|
|
246
|
-
run: (files) => analyse(files, true),
|
|
255
|
+
hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="PII-Annotations Guard"`',
|
|
256
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
257
|
+
writeBaseline: (files) => piiBaseline.write(baselineCounts(scan(files).findings)),
|
|
247
258
|
};
|
|
248
259
|
|
|
249
260
|
// Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
|
|
@@ -260,7 +271,7 @@ if (import.meta.main) {
|
|
|
260
271
|
}
|
|
261
272
|
if (args.includes("--no-baseline")) {
|
|
262
273
|
const project = buildSharedProject([guard]);
|
|
263
|
-
analyse(filesForGuard(project, guard), false);
|
|
274
|
+
analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
|
|
264
275
|
process.exit(0);
|
|
265
276
|
}
|
|
266
277
|
runStandalone(guard);
|
|
@@ -113,9 +113,8 @@ export const guard: AstGuard = {
|
|
|
113
113
|
name: "Restricted-Symbols Guard",
|
|
114
114
|
scan: SCAN,
|
|
115
115
|
hint: 'getUnscopedAggregateStream{MaxVersion,Tenant} is an existence oracle for foreign tenants — only seed-/system-internal code may reference them. New caller needed? Extend the allowlist in guard-restricted-symbols.ts, with a reason. Known gap: `export * from "...event-store"` is not detected (named imports, re-exports, and namespace property access are covered).',
|
|
116
|
-
run(files) {
|
|
116
|
+
run(files, roots = resolveRepoRoots()) {
|
|
117
117
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
118
|
-
const roots = resolveRepoRoots();
|
|
119
118
|
|
|
120
119
|
for (const sf of files) {
|
|
121
120
|
const file = sf.getFilePath();
|
|
@@ -44,9 +44,11 @@ import {
|
|
|
44
44
|
type GuardOutcome,
|
|
45
45
|
type GuardViolation,
|
|
46
46
|
isLocalFinding,
|
|
47
|
+
relFromRepoRoot,
|
|
47
48
|
runStandalone,
|
|
48
49
|
type ScanSpec,
|
|
49
50
|
} from "./_lib/guard-kit";
|
|
51
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
50
52
|
|
|
51
53
|
const ROOT = process.cwd();
|
|
52
54
|
|
|
@@ -100,8 +102,8 @@ const ARRAY_READS = new Set([
|
|
|
100
102
|
"values",
|
|
101
103
|
]);
|
|
102
104
|
|
|
103
|
-
function relFile(sf: SourceFile): string {
|
|
104
|
-
return
|
|
105
|
+
function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
|
|
106
|
+
return relFromRepoRoot(sf.getFilePath(), roots);
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
function hasReasonedIgnoreTag(node: Node): boolean {
|
|
@@ -129,7 +131,10 @@ export interface Finding {
|
|
|
129
131
|
snippet: string;
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
export function scanRawSectionFieldReads(
|
|
134
|
+
export function scanRawSectionFieldReads(
|
|
135
|
+
sf: SourceFile,
|
|
136
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
137
|
+
): Finding[] {
|
|
133
138
|
const findings: Finding[] = [];
|
|
134
139
|
for (const access of sf.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
|
|
135
140
|
if (access.getName() !== "fields") continue;
|
|
@@ -137,19 +142,23 @@ export function scanRawSectionFieldReads(sf: SourceFile): Finding[] {
|
|
|
137
142
|
if (!isSequenceRead(access)) continue;
|
|
138
143
|
if (hasReasonedIgnoreTag(access)) continue;
|
|
139
144
|
const line = access.getStartLineNumber();
|
|
140
|
-
|
|
145
|
+
const file = relFile(sf, roots);
|
|
146
|
+
findings.push({ file, line, snippet: access.getText().slice(0, 80) });
|
|
141
147
|
console.warn(
|
|
142
|
-
` [section-fields-raw WARN] ${
|
|
148
|
+
` [section-fields-raw WARN] ${file}:${line} raw read of ${access.getText().slice(0, 80)} — use sectionFieldSpecs(section)`,
|
|
143
149
|
);
|
|
144
150
|
}
|
|
145
151
|
return findings;
|
|
146
152
|
}
|
|
147
153
|
|
|
148
|
-
function scan(
|
|
154
|
+
function scan(
|
|
155
|
+
files: readonly SourceFile[],
|
|
156
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
157
|
+
): Finding[] {
|
|
149
158
|
const findings: Finding[] = [];
|
|
150
159
|
for (const sf of files) {
|
|
151
160
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
152
|
-
findings.push(...scanRawSectionFieldReads(sf));
|
|
161
|
+
findings.push(...scanRawSectionFieldReads(sf, roots));
|
|
153
162
|
}
|
|
154
163
|
return findings;
|
|
155
164
|
}
|
|
@@ -169,8 +178,12 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
|
|
|
169
178
|
return counts;
|
|
170
179
|
}
|
|
171
180
|
|
|
172
|
-
function analyse(
|
|
173
|
-
|
|
181
|
+
function analyse(
|
|
182
|
+
files: readonly SourceFile[],
|
|
183
|
+
roots: readonly RepoRoot[],
|
|
184
|
+
compareBaseline: boolean,
|
|
185
|
+
): GuardOutcome {
|
|
186
|
+
const findings = scan(files, roots);
|
|
174
187
|
if (!compareBaseline) {
|
|
175
188
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
176
189
|
return { violations: [] };
|
|
@@ -180,7 +193,8 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
|
|
|
180
193
|
baselineCounts(findings),
|
|
181
194
|
REMEDIATION,
|
|
182
195
|
{
|
|
183
|
-
formatDriftRemediation:
|
|
196
|
+
formatDriftRemediation:
|
|
197
|
+
'Run `kumiko-guards guards --write-baseline --guard="Raw section.fields Guard"` once.',
|
|
184
198
|
resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
|
|
185
199
|
},
|
|
186
200
|
);
|
|
@@ -190,8 +204,9 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
|
|
|
190
204
|
export const guard: AstGuard = {
|
|
191
205
|
name: "Raw section.fields Guard",
|
|
192
206
|
scan: SCAN,
|
|
193
|
-
hint: `${REMEDIATION} Known gap: \`section.fields.length\` is not flagged. After a deliberate change: \`
|
|
194
|
-
run: (files) => analyse(files, true),
|
|
207
|
+
hint: `${REMEDIATION} Known gap: \`section.fields.length\` is not flagged. After a deliberate change: \`kumiko-guards guards --write-baseline --guard="Raw section.fields Guard"\`.`,
|
|
208
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
209
|
+
writeBaseline: (files) => sectionFieldsRawBaseline.write(baselineCounts(scan(files))),
|
|
195
210
|
};
|
|
196
211
|
|
|
197
212
|
// Flags are read only here, never in run() — the shared runner drives every
|
|
@@ -206,7 +221,7 @@ if (import.meta.main) {
|
|
|
206
221
|
}
|
|
207
222
|
if (args.includes("--no-baseline")) {
|
|
208
223
|
const project = buildSharedProject([guard]);
|
|
209
|
-
analyse(filesForGuard(project, guard), false);
|
|
224
|
+
analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
|
|
210
225
|
process.exit(0);
|
|
211
226
|
}
|
|
212
227
|
runStandalone(guard);
|
|
@@ -21,10 +21,12 @@
|
|
|
21
21
|
* baseline fails, at or under passes. Without the file the guard is
|
|
22
22
|
* warning-only (bootstrap: `--write-baseline` once).
|
|
23
23
|
*
|
|
24
|
-
* Usage:
|
|
24
|
+
* Usage (framework dev checkout):
|
|
25
25
|
* bun guards/guard-test-timeouts.ts # compare to baseline
|
|
26
26
|
* bun guards/guard-test-timeouts.ts --write-baseline # freeze current
|
|
27
27
|
* bun guards/guard-test-timeouts.ts --no-baseline # skip comparison
|
|
28
|
+
* Consumer (via the published `kumiko-guards` CLI):
|
|
29
|
+
* kumiko-guards guards --write-baseline --guard=test-timeouts
|
|
28
30
|
*/
|
|
29
31
|
import * as path from "node:path";
|
|
30
32
|
import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
@@ -36,9 +38,11 @@ import {
|
|
|
36
38
|
type GuardOutcome,
|
|
37
39
|
type GuardViolation,
|
|
38
40
|
isLocalFinding,
|
|
41
|
+
relFromRepoRoot,
|
|
39
42
|
runStandalone,
|
|
40
43
|
type ScanSpec,
|
|
41
44
|
} from "./_lib/guard-kit";
|
|
45
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
42
46
|
|
|
43
47
|
const ROOT = process.cwd();
|
|
44
48
|
|
|
@@ -78,8 +82,8 @@ export interface Finding {
|
|
|
78
82
|
message: string;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
function relFile(sf: SourceFile): string {
|
|
82
|
-
return
|
|
85
|
+
function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
|
|
86
|
+
return relFromRepoRoot(sf.getFilePath(), roots);
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
function isLoop(node: Node): boolean {
|
|
@@ -151,9 +155,12 @@ function withExceptionNote(node: Node, message: string): string | undefined {
|
|
|
151
155
|
: message;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
export function scanTimeouts(
|
|
158
|
+
export function scanTimeouts(
|
|
159
|
+
sf: SourceFile,
|
|
160
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
161
|
+
): Finding[] {
|
|
155
162
|
if (sf.getFilePath().includes(RECIPES_DIR)) return [];
|
|
156
|
-
const file = relFile(sf);
|
|
163
|
+
const file = relFile(sf, roots);
|
|
157
164
|
const findings: Finding[] = [];
|
|
158
165
|
const add = (node: Node, message: string): void => {
|
|
159
166
|
const reported = withExceptionNote(node, message);
|
|
@@ -184,7 +191,7 @@ const timeoutsBaseline = baselineRatchet({
|
|
|
184
191
|
const REMEDIATION = `Wait for a condition (\`waitFor\`, \`expect.poll\`) instead of raising timeouts or sleeping, or mark the line with \`// ${EXCEPTION_TAG}: #<issue> <technical reason>\`.`;
|
|
185
192
|
|
|
186
193
|
const HINT =
|
|
187
|
-
"Diagnose in this order: (1) reproduce a single run (`--repeat-each`, single file), (2) check shared state and missing isolation (one tenant per flow), (3) wait for a condition, not for time (`waitFor`, `expect.poll`), (4) check the seed, (5) only then change the central template via an issue — never per app. See docs/guides/test-failures.md. After a deliberate change: `
|
|
194
|
+
"Diagnose in this order: (1) reproduce a single run (`--repeat-each`, single file), (2) check shared state and missing isolation (one tenant per flow), (3) wait for a condition, not for time (`waitFor`, `expect.poll`), (4) check the seed, (5) only then change the central template via an issue — never per app. See https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md. After a deliberate change: `kumiko-guards guards --write-baseline --guard=test-timeouts`.";
|
|
188
195
|
|
|
189
196
|
export function baselineCounts(findings: readonly Finding[]): Record<string, number> {
|
|
190
197
|
const counts: Record<string, number> = {};
|
|
@@ -194,10 +201,13 @@ export function baselineCounts(findings: readonly Finding[]): Record<string, num
|
|
|
194
201
|
return counts;
|
|
195
202
|
}
|
|
196
203
|
|
|
197
|
-
function scan(
|
|
204
|
+
function scan(
|
|
205
|
+
files: readonly SourceFile[],
|
|
206
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
207
|
+
): Finding[] {
|
|
198
208
|
const findings: Finding[] = [];
|
|
199
209
|
for (const sf of files) {
|
|
200
|
-
for (const finding of scanTimeouts(sf)) {
|
|
210
|
+
for (const finding of scanTimeouts(sf, roots)) {
|
|
201
211
|
findings.push(finding);
|
|
202
212
|
console.warn(` [test-timeouts WARN] ${finding.file}:${finding.line} ${finding.message}`);
|
|
203
213
|
}
|
|
@@ -205,8 +215,12 @@ function scan(files: readonly SourceFile[]): Finding[] {
|
|
|
205
215
|
return findings;
|
|
206
216
|
}
|
|
207
217
|
|
|
208
|
-
function analyse(
|
|
209
|
-
|
|
218
|
+
function analyse(
|
|
219
|
+
files: readonly SourceFile[],
|
|
220
|
+
roots: readonly RepoRoot[],
|
|
221
|
+
compareBaseline: boolean,
|
|
222
|
+
): GuardOutcome {
|
|
223
|
+
const findings = scan(files, roots);
|
|
210
224
|
if (!compareBaseline) {
|
|
211
225
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
212
226
|
return { violations: [] };
|
|
@@ -216,7 +230,8 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
|
|
|
216
230
|
baselineCounts(findings),
|
|
217
231
|
REMEDIATION,
|
|
218
232
|
{
|
|
219
|
-
formatDriftRemediation:
|
|
233
|
+
formatDriftRemediation:
|
|
234
|
+
"Run `kumiko-guards guards --write-baseline --guard=test-timeouts` once.",
|
|
220
235
|
resolveLine: (file) => local.find((f) => f.file === file)?.line ?? 1,
|
|
221
236
|
},
|
|
222
237
|
);
|
|
@@ -227,7 +242,8 @@ export const guard: AstGuard = {
|
|
|
227
242
|
name: "test-timeouts",
|
|
228
243
|
scan: SCAN,
|
|
229
244
|
hint: HINT,
|
|
230
|
-
run: (files) => analyse(files, true),
|
|
245
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
246
|
+
writeBaseline: (files) => timeoutsBaseline.write(baselineCounts(scan(files))),
|
|
231
247
|
};
|
|
232
248
|
|
|
233
249
|
// Flags are read only here, never in run() — the shared runner drives every
|
|
@@ -237,12 +253,12 @@ if (import.meta.main) {
|
|
|
237
253
|
const args = process.argv.slice(2);
|
|
238
254
|
if (args.includes("--write-baseline")) {
|
|
239
255
|
const project = buildSharedProject([guard]);
|
|
240
|
-
|
|
256
|
+
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
241
257
|
process.exit(0);
|
|
242
258
|
}
|
|
243
259
|
if (args.includes("--no-baseline")) {
|
|
244
260
|
const project = buildSharedProject([guard]);
|
|
245
|
-
analyse(filesForGuard(project, guard), false);
|
|
261
|
+
analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
|
|
246
262
|
process.exit(0);
|
|
247
263
|
}
|
|
248
264
|
runStandalone(guard);
|
|
@@ -39,9 +39,11 @@ import {
|
|
|
39
39
|
type GuardOutcome,
|
|
40
40
|
type GuardViolation,
|
|
41
41
|
isLocalFinding,
|
|
42
|
+
relFromRepoRoot,
|
|
42
43
|
runStandalone,
|
|
43
44
|
type ScanSpec,
|
|
44
45
|
} from "./_lib/guard-kit";
|
|
46
|
+
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
45
47
|
|
|
46
48
|
const ROOT = process.cwd();
|
|
47
49
|
|
|
@@ -61,8 +63,8 @@ const FIELD_FACTORY_CALLEES = new Set(["createTextField", "createLongTextField"]
|
|
|
61
63
|
const VALID_PERSONAL_HINT =
|
|
62
64
|
'{ personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false } ("false" additionally needs { reason: "..." })';
|
|
63
65
|
|
|
64
|
-
function relFile(sf: SourceFile): string {
|
|
65
|
-
return
|
|
66
|
+
function relFile(sf: SourceFile, roots: readonly RepoRoot[]): string {
|
|
67
|
+
return relFromRepoRoot(sf.getFilePath(), roots);
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
function fieldFactoryOptions(call: CallExpression): ObjectLiteralExpression | undefined {
|
|
@@ -110,7 +112,7 @@ export interface Finding {
|
|
|
110
112
|
callee: string;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
function scanFieldFactories(sf: SourceFile): Finding[] {
|
|
115
|
+
function scanFieldFactories(sf: SourceFile, roots: readonly RepoRoot[]): Finding[] {
|
|
114
116
|
const findings: Finding[] = [];
|
|
115
117
|
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
116
118
|
const callee = call.getExpression().getText();
|
|
@@ -126,15 +128,19 @@ function scanFieldFactories(sf: SourceFile): Finding[] {
|
|
|
126
128
|
|
|
127
129
|
const place = enclosingFieldName(call) ?? `${callee}(...)`;
|
|
128
130
|
const line = call.getStartLineNumber();
|
|
129
|
-
|
|
131
|
+
const file = relFile(sf, roots);
|
|
132
|
+
findings.push({ file, line, place, callee });
|
|
130
133
|
console.warn(
|
|
131
|
-
` [text-field-stance WARN] ${
|
|
134
|
+
` [text-field-stance WARN] ${file}:${line} ${callee}(...) at "${place}" has no personal stance — mark ${VALID_PERSONAL_HINT}`,
|
|
132
135
|
);
|
|
133
136
|
}
|
|
134
137
|
return findings;
|
|
135
138
|
}
|
|
136
139
|
|
|
137
|
-
function scan(
|
|
140
|
+
function scan(
|
|
141
|
+
files: readonly SourceFile[],
|
|
142
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
143
|
+
): {
|
|
138
144
|
findings: Finding[];
|
|
139
145
|
scanned: number;
|
|
140
146
|
} {
|
|
@@ -143,7 +149,7 @@ function scan(files: readonly SourceFile[]): {
|
|
|
143
149
|
for (const sf of files) {
|
|
144
150
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
145
151
|
scanned++;
|
|
146
|
-
findings.push(...scanFieldFactories(sf));
|
|
152
|
+
findings.push(...scanFieldFactories(sf, roots));
|
|
147
153
|
}
|
|
148
154
|
return { findings, scanned };
|
|
149
155
|
}
|
|
@@ -179,14 +185,18 @@ function checkBaseline(findings: readonly Finding[]): GuardViolation[] {
|
|
|
179
185
|
baselineCounts(findings),
|
|
180
186
|
`Annotate the call (${VALID_PERSONAL_HINT}).`,
|
|
181
187
|
{
|
|
182
|
-
formatDriftRemediation: `Run \`
|
|
188
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard="Text-Field Personal-Stance Guard"\` once.`,
|
|
183
189
|
resolveLine,
|
|
184
190
|
},
|
|
185
191
|
);
|
|
186
192
|
}
|
|
187
193
|
|
|
188
|
-
function analyse(
|
|
189
|
-
|
|
194
|
+
function analyse(
|
|
195
|
+
files: readonly SourceFile[],
|
|
196
|
+
roots: readonly RepoRoot[],
|
|
197
|
+
compareBaseline: boolean,
|
|
198
|
+
): GuardOutcome {
|
|
199
|
+
const { findings } = scan(files, roots);
|
|
190
200
|
if (!compareBaseline) {
|
|
191
201
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
192
202
|
return { violations: [] };
|
|
@@ -197,8 +207,9 @@ function analyse(files: readonly SourceFile[], compareBaseline: boolean): GuardO
|
|
|
197
207
|
export const guard: AstGuard = {
|
|
198
208
|
name: "Text-Field Personal-Stance Guard",
|
|
199
209
|
scan: SCAN,
|
|
200
|
-
hint:
|
|
201
|
-
run: (files) => analyse(files, true),
|
|
210
|
+
hint: 'after a deliberate annotation: `kumiko-guards guards --write-baseline --guard="Text-Field Personal-Stance Guard"`',
|
|
211
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
212
|
+
writeBaseline: (files) => textFieldStanceBaseline.write(baselineCounts(scan(files).findings)),
|
|
202
213
|
};
|
|
203
214
|
|
|
204
215
|
// Flags werden NUR hier gelesen, nicht in run() — der Shared-Runner
|
|
@@ -215,7 +226,7 @@ if (import.meta.main) {
|
|
|
215
226
|
}
|
|
216
227
|
if (args.includes("--no-baseline")) {
|
|
217
228
|
const project = buildSharedProject([guard]);
|
|
218
|
-
analyse(filesForGuard(project, guard), false);
|
|
229
|
+
analyse(filesForGuard(project, guard), resolveRepoRoots(), false);
|
|
219
230
|
process.exit(0);
|
|
220
231
|
}
|
|
221
232
|
runStandalone(guard);
|
|
@@ -183,8 +183,7 @@ export const guard: AstGuard = {
|
|
|
183
183
|
name: "Write-Handler-QN Guard",
|
|
184
184
|
scan: SCAN,
|
|
185
185
|
hint: "Typo in the write-handler QN? Check feature name + handler name.",
|
|
186
|
-
run(files) {
|
|
187
|
-
const roots = resolveRepoRoots();
|
|
186
|
+
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
188
187
|
const knownQnsByRepo = loadKnownQnsByRepo(roots);
|
|
189
188
|
|
|
190
189
|
const violations: Array<{
|
package/src/pre-push.sh
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env sh
|
|
2
|
+
# Pre-push hook — runs scoped `bun check` before allowing push.
|
|
3
|
+
#
|
|
4
|
+
# Logic:
|
|
5
|
+
# 1. If running inside the cosmicdriftgamestudio Parent-Workspace,
|
|
6
|
+
# delegate to its `bun check` with KUMIKO_CLI_SCOPE set to THIS
|
|
7
|
+
# sub-repo only — so a push from one sub-repo doesn't run
|
|
8
|
+
# Biome/TS/Tests for its siblings as well.
|
|
9
|
+
# 2. Else (standalone clone outside the parent workspace): run the
|
|
10
|
+
# sub-repo's own package.json `test` script.
|
|
11
|
+
#
|
|
12
|
+
# Extension point: a tracked, executable scripts/pre-push-extra.sh runs
|
|
13
|
+
# before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
|
|
14
|
+
# and KUMIKO_PUSH_PARENT_DIR available inline (never exported).
|
|
15
|
+
#
|
|
16
|
+
# Override: set PRE_PUSH_SKIP=1 to bypass (use sparingly).
|
|
17
|
+
|
|
18
|
+
if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
|
|
19
|
+
echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
|
|
20
|
+
exit 0
|
|
21
|
+
fi
|
|
22
|
+
|
|
23
|
+
# Git exports GIT_DIR & co. to hooks; nothing this hook starts may inherit them,
|
|
24
|
+
# or a test's fixture git calls write into this repo.
|
|
25
|
+
LOCAL_GIT_ENV="$(git rev-parse --local-env-vars)" || { echo "[pre-push] FATAL: git rev-parse --local-env-vars failed" >&2; exit 1; }
|
|
26
|
+
# shellcheck disable=SC2086
|
|
27
|
+
unset $LOCAL_GIT_ENV
|
|
28
|
+
|
|
29
|
+
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
|
30
|
+
if [ -z "$REPO_ROOT" ]; then
|
|
31
|
+
echo "[pre-push] FATAL: git rev-parse --show-toplevel failed — refusing push" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
# --git-common-dir resolves to the main checkout's .git dir even when
|
|
35
|
+
# REPO_ROOT is a `.wt/<name>` worktree, so REPO_NAME stays the repo's
|
|
36
|
+
# own name instead of the worktree directory name.
|
|
37
|
+
# Empty result (old git without --path-format, or rev-parse failure) would
|
|
38
|
+
# make basename/dirname yield ".", fail-open-scoping the push — refuse instead.
|
|
39
|
+
GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
|
|
40
|
+
if [ -z "$GIT_COMMON_DIR" ]; then
|
|
41
|
+
echo "[pre-push] FATAL: git rev-parse --git-common-dir failed — refusing push (would scope as '.')" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
fi
|
|
44
|
+
REPO_NAME="$(basename "$(dirname "$GIT_COMMON_DIR")")"
|
|
45
|
+
if [ -z "$REPO_NAME" ] || [ "$REPO_NAME" = "." ] || [ "$REPO_NAME" = "/" ]; then
|
|
46
|
+
echo "[pre-push] FATAL: could not derive REPO_NAME from git-common-dir='$GIT_COMMON_DIR' — refusing push" >&2
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Walk up from REPO_ROOT looking for the parent workspace's package.json —
|
|
51
|
+
# a `.wt/<name>` worktree sits one level deeper than a regular checkout.
|
|
52
|
+
PARENT_DIR=""
|
|
53
|
+
DIR="$(dirname "$REPO_ROOT")"
|
|
54
|
+
while [ "$DIR" != "/" ]; do
|
|
55
|
+
if [ -f "$DIR/package.json" ] \
|
|
56
|
+
&& grep -q '"name": *"cosmicdriftgamestudio"' "$DIR/package.json" 2>/dev/null; then
|
|
57
|
+
PARENT_DIR="$DIR"
|
|
58
|
+
break
|
|
59
|
+
fi
|
|
60
|
+
DIR="$(dirname "$DIR")"
|
|
61
|
+
done
|
|
62
|
+
|
|
63
|
+
if git -C "$REPO_ROOT" ls-files --error-unmatch scripts/pre-push-extra.sh >/dev/null 2>&1 \
|
|
64
|
+
&& [ -x "$REPO_ROOT/scripts/pre-push-extra.sh" ]; then
|
|
65
|
+
echo "[pre-push] running scripts/pre-push-extra.sh…"
|
|
66
|
+
(cd "$REPO_ROOT" && KUMIKO_PUSH_REPO_ROOT="$REPO_ROOT" KUMIKO_PUSH_PARENT_DIR="$PARENT_DIR" "$REPO_ROOT/scripts/pre-push-extra.sh" "$@") || exit 1
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
|
|
70
|
+
# checkout's .git, so the branch below would scope `bun check` to the
|
|
71
|
+
# canonical sibling path instead of this worktree — checking the wrong
|
|
72
|
+
# code. Run the worktree-local check only when scripts/check-wt.sh is
|
|
73
|
+
# tracked and executable (untracked/symlink stubs must not hijack push).
|
|
74
|
+
if [ "$(git rev-parse --git-dir)" != "$(git rev-parse --git-common-dir)" ] \
|
|
75
|
+
&& git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
|
|
76
|
+
&& [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
|
|
77
|
+
echo "[pre-push] worktree detected — worktree check (see scripts/check-wt.sh)…"
|
|
78
|
+
cd "$REPO_ROOT" && exec "$REPO_ROOT/scripts/check-wt.sh"
|
|
79
|
+
fi
|
|
80
|
+
|
|
81
|
+
if [ -n "$PARENT_DIR" ]; then
|
|
82
|
+
echo "[pre-push] bun check (scoped: $REPO_NAME)…"
|
|
83
|
+
# KUMIKO_PUSH_REPO_ROOT forwards this checkout to guards that need it
|
|
84
|
+
# (worktrees with scripts/check-wt.sh take the branch above and skip this path).
|
|
85
|
+
cd "$PARENT_DIR" && KUMIKO_CLI_SCOPE="$REPO_NAME" KUMIKO_PUSH_REPO_ROOT="$REPO_ROOT" bun kumiko-framework/bin/kumiko.ts check
|
|
86
|
+
else
|
|
87
|
+
echo "[pre-push] bun run test (standalone)…"
|
|
88
|
+
# `bun test` globs everything the default bunfig doesn't exclude, including
|
|
89
|
+
# integration tests; the package.json `test` script carries the repo's own
|
|
90
|
+
# exclusions (e.g. integration suites needing infra this hook doesn't have).
|
|
91
|
+
cd "$REPO_ROOT" && bun run test
|
|
92
|
+
fi
|
package/src/run-guards.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
buildSharedProject,
|
|
12
12
|
cliFlagsError,
|
|
13
13
|
explainGuards,
|
|
14
|
+
filesForGuard,
|
|
14
15
|
isSecurityGuard,
|
|
15
16
|
printGuardKitBanner,
|
|
16
17
|
reportResults,
|
|
@@ -95,31 +96,61 @@ export const GUARD_FLAGS = [
|
|
|
95
96
|
"--explain",
|
|
96
97
|
"--write-security-baseline",
|
|
97
98
|
"--strict-security-baseline",
|
|
99
|
+
"--write-baseline",
|
|
98
100
|
] as const;
|
|
99
101
|
|
|
102
|
+
const GUARD_NAME_PREFIX = "--guard=";
|
|
103
|
+
|
|
100
104
|
// Shared by the direct `bun run-guards.ts` invocation below and by the
|
|
101
105
|
// `guards` subcommand in cli.ts — one place for the flag behavior so the
|
|
102
106
|
// two entry points can never drift.
|
|
103
107
|
export function runGuardsCli(argv: readonly string[]): number {
|
|
104
|
-
const
|
|
108
|
+
const guardNameArg = argv.find((arg) => arg.startsWith(GUARD_NAME_PREFIX));
|
|
109
|
+
const flags = guardNameArg === undefined ? argv : argv.filter((arg) => arg !== guardNameArg);
|
|
110
|
+
const flagsError = cliFlagsError("guards", flags, GUARD_FLAGS);
|
|
105
111
|
if (flagsError !== undefined) {
|
|
106
112
|
console.error(flagsError);
|
|
107
113
|
return 1;
|
|
108
114
|
}
|
|
109
|
-
if (
|
|
115
|
+
if (flags.includes("--explain")) {
|
|
110
116
|
for (const line of explainGuards(GUARDS, buildSharedProject(GUARDS))) {
|
|
111
117
|
console.log(line);
|
|
112
118
|
}
|
|
113
119
|
return 0;
|
|
114
120
|
}
|
|
115
|
-
if (
|
|
121
|
+
if (flags.includes("--write-security-baseline")) {
|
|
116
122
|
writeSecurityBaselines(
|
|
117
123
|
GUARDS.filter(isSecurityGuard),
|
|
118
124
|
buildSharedProject(GUARDS.filter(isSecurityGuard)),
|
|
119
125
|
);
|
|
120
126
|
return 0;
|
|
121
127
|
}
|
|
122
|
-
|
|
128
|
+
// --write-baseline always needs --guard=<name> — baselines get frozen
|
|
129
|
+
// deliberately, one at a time, never as a drive-by "refreeze everything".
|
|
130
|
+
if (flags.includes("--write-baseline")) {
|
|
131
|
+
if (guardNameArg === undefined) {
|
|
132
|
+
console.error(
|
|
133
|
+
"--write-baseline needs --guard=<name>. Freeze a baseline only deliberately, one guard at a time — fix the cause first (https://github.com/CosmicDriftGameStudio/kumiko-framework/blob/main/docs/guides/test-failures.md).",
|
|
134
|
+
);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
const guardName = guardNameArg.slice(GUARD_NAME_PREFIX.length);
|
|
138
|
+
const target = GUARDS.find((g) => g.name === guardName);
|
|
139
|
+
if (target === undefined) {
|
|
140
|
+
console.error(
|
|
141
|
+
`Unknown guard "${guardName}". Known guard names: ${GUARDS.map((g) => g.name).join(", ")}`,
|
|
142
|
+
);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
if (target.writeBaseline === undefined) {
|
|
146
|
+
console.error(`Guard "${guardName}" has no ratchet baseline to write.`);
|
|
147
|
+
return 1;
|
|
148
|
+
}
|
|
149
|
+
const project = buildSharedProject([target]);
|
|
150
|
+
target.writeBaseline(filesForGuard(project, target));
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
const strictSecurityBaseline = flags.includes("--strict-security-baseline");
|
|
123
154
|
const guards = strictSecurityBaseline ? GUARDS.filter(isSecurityGuard) : GUARDS;
|
|
124
155
|
const project = buildSharedProject(guards);
|
|
125
156
|
printGuardKitBanner(guards.length, project);
|