@cosmicdrift/kumiko-guards 0.374.0 → 0.378.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 +6 -4
- package/hooks/pre-push +26 -12
- package/package.json +23 -4
- package/src/_lib/agent-docs.ts +2 -2
- package/src/_lib/primitives-access.ts +9 -5
- package/src/_lib/roots.ts +3 -3
- package/src/_lib/scan-scope.ts +1 -1
- package/src/changes.json +62 -0
- package/src/guard-agent-docs-drift.ts +1 -1
- package/src/guard-api-object-form.ts +42 -28
- package/src/guard-comment-lang.ts +30 -1
- package/src/guard-direct-entity-writes.ts +37 -5
- package/src/guard-direct-fetch.ts +2 -2
- package/src/guard-escape-hatch-declared.ts +14 -0
- package/src/guard-html-escape.ts +2 -2
- package/src/guard-i18n-keys.ts +63 -4
- package/src/guard-no-custom-primitives.ts +2 -2
- package/src/guard-no-direct-fs.ts +0 -1
- package/src/guard-pre-es-patterns.ts +4 -0
- package/src/guard-primitives-discipline.ts +6 -9
- package/src/guard-raw-classname.ts +1 -1
- package/src/guard-raw-interactive-elements.ts +4 -4
- package/src/guard-renderer-boundaries.ts +35 -9
- package/src/guard-section-fields-raw.ts +1 -1
- package/src/guard-tailwind-scan-surface.ts +54 -20
- package/src/guard-unsafe-append-event.ts +125 -0
- package/src/pre-push.sh +5 -10
- package/src/repo-manifest/app-ports/index.ts +13 -0
- package/src/repo-manifest/app-ports/port-registry.ts +45 -0
- package/src/repo-manifest/app-ports/static-app-config.ts +121 -0
- package/src/repo-manifest/index.ts +262 -0
- package/src/run-guards.ts +2 -0
package/src/pre-push.sh
CHANGED
|
@@ -24,13 +24,6 @@
|
|
|
24
24
|
# Extension point: a tracked, executable scripts/pre-push-extra.sh runs
|
|
25
25
|
# before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
|
|
26
26
|
# and KUMIKO_PUSH_PARENT_DIR available inline (never exported).
|
|
27
|
-
#
|
|
28
|
-
# Override: set PRE_PUSH_SKIP=1 to bypass (use sparingly).
|
|
29
|
-
|
|
30
|
-
if [ "${PRE_PUSH_SKIP:-0}" = "1" ]; then
|
|
31
|
-
echo "[pre-push] PRE_PUSH_SKIP=1 — hook skipped"
|
|
32
|
-
exit 0
|
|
33
|
-
fi
|
|
34
27
|
|
|
35
28
|
# Git exports GIT_DIR & co. to hooks; nothing this hook starts may inherit them,
|
|
36
29
|
# or a test's fixture git calls write into this repo.
|
|
@@ -142,7 +135,9 @@ fi
|
|
|
142
135
|
# canonical sibling path instead of this worktree — checking the wrong
|
|
143
136
|
# code. Run the worktree-local check only when scripts/check-wt.sh is
|
|
144
137
|
# tracked and executable (untracked/symlink stubs must not hijack push).
|
|
145
|
-
|
|
138
|
+
# A check-wt.sh that wraps this hook sets KUMIKO_CHECK_WT=1 so it is not re-entered.
|
|
139
|
+
if [ "${KUMIKO_CHECK_WT:-0}" != "1" ] \
|
|
140
|
+
&& [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ] \
|
|
146
141
|
&& git -C "$REPO_ROOT" ls-files --error-unmatch scripts/check-wt.sh >/dev/null 2>&1 \
|
|
147
142
|
&& [ -x "$REPO_ROOT/scripts/check-wt.sh" ]; then
|
|
148
143
|
echo "[pre-push] worktree detected — worktree check (see scripts/check-wt.sh)…"
|
|
@@ -178,7 +173,7 @@ if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ]
|
|
|
178
173
|
echo "[pre-push] worktree without scripts/check-wt.sh — running this worktree's package.json scripts (typecheck, lint, test, test:dom) and guards (guards, checks, comment-lang)…"
|
|
179
174
|
if ! has_package_script test; then
|
|
180
175
|
echo "[pre-push] FATAL: worktree without a package.json \"test\" script — nothing to run, refusing push." >&2
|
|
181
|
-
echo "Add a \"test\" script to package.json
|
|
176
|
+
echo "Add a \"test\" script to package.json." >&2
|
|
182
177
|
exit 1
|
|
183
178
|
fi
|
|
184
179
|
FAILED=""
|
|
@@ -218,7 +213,7 @@ else
|
|
|
218
213
|
# "Script not found" — check it up front with a clear, actionable message.
|
|
219
214
|
if ! has_package_script test; then
|
|
220
215
|
echo "[pre-push] FATAL: standalone clone without a package.json \"test\" script — nothing to run, refusing push." >&2
|
|
221
|
-
echo "Add a \"test\" script to package.json
|
|
216
|
+
echo "Add a \"test\" script to package.json." >&2
|
|
222
217
|
exit 1
|
|
223
218
|
fi
|
|
224
219
|
cd "$REPO_ROOT" && bun run test
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { listAppPorts } from "@cosmicdrift/kumiko-framework/config";
|
|
2
|
+
export {
|
|
3
|
+
findPortCollisions,
|
|
4
|
+
type PortClaim,
|
|
5
|
+
type PortCollision,
|
|
6
|
+
type ScannedApp,
|
|
7
|
+
scanWorkspaceApps,
|
|
8
|
+
} from "./port-registry.ts";
|
|
9
|
+
export {
|
|
10
|
+
parseStaticAppConfig,
|
|
11
|
+
readStaticAppConfig,
|
|
12
|
+
type StaticAppConfig,
|
|
13
|
+
} from "./static-app-config.ts";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Port registry across the apps of a workspace: every app declares its ports
|
|
2
|
+
// in kumiko.config.ts, and two apps (or two roles of one app) must never claim
|
|
3
|
+
// the same port, otherwise their local dev and E2E runs block each other.
|
|
4
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
APP_CONFIG_FILE,
|
|
8
|
+
type AppPortRole,
|
|
9
|
+
listAppPorts,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/config";
|
|
11
|
+
import { readStaticAppConfig, type StaticAppConfig } from "./static-app-config.ts";
|
|
12
|
+
|
|
13
|
+
export type ScannedApp = { readonly app: string; readonly config: StaticAppConfig };
|
|
14
|
+
|
|
15
|
+
export type PortClaim = { readonly app: string; readonly role: AppPortRole };
|
|
16
|
+
|
|
17
|
+
export type PortCollision = { readonly port: number; readonly claims: readonly PortClaim[] };
|
|
18
|
+
|
|
19
|
+
/** Direct children of `root` with a kumiko.config.ts; worktrees and dot-dirs are skipped. */
|
|
20
|
+
export function scanWorkspaceApps(root: string): readonly ScannedApp[] {
|
|
21
|
+
return readdirSync(root, { withFileTypes: true })
|
|
22
|
+
.filter(
|
|
23
|
+
(entry) =>
|
|
24
|
+
entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules",
|
|
25
|
+
)
|
|
26
|
+
.filter((entry) => existsSync(join(root, entry.name, APP_CONFIG_FILE)))
|
|
27
|
+
.map((entry) => ({ app: entry.name, config: readStaticAppConfig(join(root, entry.name)) }))
|
|
28
|
+
.sort((a, b) => a.app.localeCompare(b.app));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function findPortCollisions(apps: readonly ScannedApp[]): readonly PortCollision[] {
|
|
32
|
+
const claimsByPort = new Map<number, PortClaim[]>();
|
|
33
|
+
for (const { app, config } of apps) {
|
|
34
|
+
if (config.kind !== "defined") continue;
|
|
35
|
+
for (const { role, port } of listAppPorts(config.ports)) {
|
|
36
|
+
const claims = claimsByPort.get(port) ?? [];
|
|
37
|
+
claims.push({ app, role });
|
|
38
|
+
claimsByPort.set(port, claims);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return [...claimsByPort.entries()]
|
|
42
|
+
.filter(([, claims]) => claims.length > 1)
|
|
43
|
+
.map(([port, claims]) => ({ port, claims }))
|
|
44
|
+
.sort((a, b) => a.port - b.port);
|
|
45
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Reads the ports out of kumiko.config.ts without importing it: importing
|
|
2
|
+
// would execute every feature module of the app, and the port registry scans
|
|
3
|
+
// sibling apps whose dependencies may not even be installed.
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
APP_CONFIG_FILE,
|
|
8
|
+
type AppInfraPorts,
|
|
9
|
+
type AppPorts,
|
|
10
|
+
findAppPortProblems,
|
|
11
|
+
} from "@cosmicdrift/kumiko-framework/config";
|
|
12
|
+
import { type Node, type ObjectLiteralExpression, Project, SyntaxKind } from "ts-morph";
|
|
13
|
+
|
|
14
|
+
export type StaticAppConfig =
|
|
15
|
+
| { readonly kind: "missing" }
|
|
16
|
+
/** A config without defineAppConfig: the app has not adopted the standard yet. */
|
|
17
|
+
| { readonly kind: "legacy" }
|
|
18
|
+
| { readonly kind: "invalid"; readonly message: string }
|
|
19
|
+
| { readonly kind: "defined"; readonly ports: AppPorts };
|
|
20
|
+
|
|
21
|
+
const INFRA_KEYS = [
|
|
22
|
+
"postgres",
|
|
23
|
+
"redis",
|
|
24
|
+
"meilisearch",
|
|
25
|
+
"s3",
|
|
26
|
+
] as const satisfies readonly (keyof AppInfraPorts)[];
|
|
27
|
+
|
|
28
|
+
type InfraKey = (typeof INFRA_KEYS)[number];
|
|
29
|
+
|
|
30
|
+
function isInfraKey(name: string): name is InfraKey {
|
|
31
|
+
return INFRA_KEYS.some((key) => key === name);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class StaticConfigError extends Error {}
|
|
35
|
+
|
|
36
|
+
function propertyName(node: Node): string | undefined {
|
|
37
|
+
if (node.isKind(SyntaxKind.PropertyAssignment)) return node.getName().replace(/^["']|["']$/g, "");
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function objectProperty(object: ObjectLiteralExpression, name: string): Node | undefined {
|
|
42
|
+
for (const property of object.getProperties()) {
|
|
43
|
+
if (
|
|
44
|
+
property.isKind(SyntaxKind.SpreadAssignment) ||
|
|
45
|
+
property.isKind(SyntaxKind.ShorthandPropertyAssignment)
|
|
46
|
+
) {
|
|
47
|
+
throw new StaticConfigError(
|
|
48
|
+
`${name}: spread and shorthand properties cannot be read statically`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (propertyName(property) === name && property.isKind(SyntaxKind.PropertyAssignment)) {
|
|
52
|
+
return property.getInitializerOrThrow();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function numericLiteral(node: Node | undefined, path: string): number {
|
|
59
|
+
if (node === undefined) throw new StaticConfigError(`${path} is missing`);
|
|
60
|
+
if (!node.isKind(SyntaxKind.NumericLiteral)) {
|
|
61
|
+
throw new StaticConfigError(
|
|
62
|
+
`${path} must be a number literal so tooling can read it, got "${node.getText()}"`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return node.getLiteralValue();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function objectLiteral(node: Node | undefined, path: string): ObjectLiteralExpression {
|
|
69
|
+
if (node === undefined) throw new StaticConfigError(`${path} is missing`);
|
|
70
|
+
if (!node.isKind(SyntaxKind.ObjectLiteralExpression)) {
|
|
71
|
+
throw new StaticConfigError(`${path} must be an object literal, got "${node.getText()}"`);
|
|
72
|
+
}
|
|
73
|
+
return node;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readInfraPorts(node: Node | undefined): AppInfraPorts | undefined {
|
|
77
|
+
if (node === undefined) return undefined;
|
|
78
|
+
const object = objectLiteral(node, "ports.infra");
|
|
79
|
+
const infra: Partial<Record<InfraKey, number>> = {};
|
|
80
|
+
for (const property of object.getProperties()) {
|
|
81
|
+
const name = propertyName(property);
|
|
82
|
+
if (name === undefined || !isInfraKey(name)) {
|
|
83
|
+
throw new StaticConfigError(
|
|
84
|
+
`ports.infra: unknown entry "${property.getText()}", expected ${INFRA_KEYS.join(", ")}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
infra[name] = numericLiteral(objectProperty(object, name), `ports.infra.${name}`);
|
|
88
|
+
}
|
|
89
|
+
return infra;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseStaticAppConfig(source: string): StaticAppConfig {
|
|
93
|
+
const project = new Project({ useInMemoryFileSystem: true, skipFileDependencyResolution: true });
|
|
94
|
+
const file = project.createSourceFile(APP_CONFIG_FILE, source);
|
|
95
|
+
const call = file
|
|
96
|
+
.getDescendantsOfKind(SyntaxKind.CallExpression)
|
|
97
|
+
.find((candidate) => candidate.getExpression().getText() === "defineAppConfig");
|
|
98
|
+
if (call === undefined) return { kind: "legacy" };
|
|
99
|
+
try {
|
|
100
|
+
const config = objectLiteral(call.getArguments()[0], "defineAppConfig argument");
|
|
101
|
+
const ports = objectLiteral(objectProperty(config, "ports"), "ports");
|
|
102
|
+
const infra = readInfraPorts(objectProperty(ports, "infra"));
|
|
103
|
+
const parsed: AppPorts = {
|
|
104
|
+
dev: numericLiteral(objectProperty(ports, "dev"), "ports.dev"),
|
|
105
|
+
e2e: numericLiteral(objectProperty(ports, "e2e"), "ports.e2e"),
|
|
106
|
+
...(infra === undefined ? {} : { infra }),
|
|
107
|
+
};
|
|
108
|
+
const problems = findAppPortProblems(parsed);
|
|
109
|
+
if (problems.length > 0) return { kind: "invalid", message: problems.join("; ") };
|
|
110
|
+
return { kind: "defined", ports: parsed };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof StaticConfigError) return { kind: "invalid", message: error.message };
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readStaticAppConfig(appRoot: string): StaticAppConfig {
|
|
118
|
+
const path = join(appRoot, APP_CONFIG_FILE);
|
|
119
|
+
if (!existsSync(path)) return { kind: "missing" };
|
|
120
|
+
return parseStaticAppConfig(readFileSync(path, "utf-8"));
|
|
121
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve, sep } from "node:path";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
|
|
5
|
+
export const REPO_MANIFEST_FILE = "kumiko.json";
|
|
6
|
+
|
|
7
|
+
// "tooling" is a repo with no product code (infra, Pulumi, build tooling) — guards default to skipping it, see scan-scope.ts.
|
|
8
|
+
export const repoKindSchema = z.enum(["framework", "library", "app", "tooling"]);
|
|
9
|
+
export type RepoKind = z.infer<typeof repoKindSchema>;
|
|
10
|
+
|
|
11
|
+
const DRIVE_LETTER_RE = /^[A-Za-z]:/;
|
|
12
|
+
|
|
13
|
+
function isBlank(value: string): boolean {
|
|
14
|
+
return value.trim().length === 0;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function patternViolation(value: string): string | undefined {
|
|
18
|
+
if (isBlank(value)) return `must not be empty: "${value}"`;
|
|
19
|
+
if (value.includes("\0")) return `must not contain a NUL character: "${value}"`;
|
|
20
|
+
if (value.startsWith("/") || DRIVE_LETTER_RE.test(value)) {
|
|
21
|
+
return `must be relative to the repo root: "${value}"`;
|
|
22
|
+
}
|
|
23
|
+
if (value.includes("\\")) return `must use forward slashes, not backslashes: "${value}"`;
|
|
24
|
+
if (value.startsWith("!")) return `must not start with "!": "${value}"`;
|
|
25
|
+
if (value.split("/").some((segment) => segment === "..")) {
|
|
26
|
+
return `must not traverse outside the repo root: "${value}"`;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pattern = z.string().superRefine((value, ctx) => {
|
|
32
|
+
const violation = patternViolation(value);
|
|
33
|
+
if (violation !== undefined) {
|
|
34
|
+
ctx.addIssue({ code: "custom", message: violation });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const repoManifestSchema = z.strictObject({
|
|
39
|
+
kind: repoKindSchema,
|
|
40
|
+
sourceRoots: z.array(pattern).min(1),
|
|
41
|
+
testGlobs: z.array(pattern).min(1),
|
|
42
|
+
uiRoots: z.array(pattern).optional(),
|
|
43
|
+
excludes: z.array(pattern).optional(),
|
|
44
|
+
});
|
|
45
|
+
export type RepoManifest = z.infer<typeof repoManifestSchema>;
|
|
46
|
+
|
|
47
|
+
export type RepoManifestSource = "file" | "derived";
|
|
48
|
+
|
|
49
|
+
export type LoadedRepoManifest = {
|
|
50
|
+
readonly manifest: RepoManifest;
|
|
51
|
+
readonly source: RepoManifestSource;
|
|
52
|
+
readonly manifestPath: string;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export class RepoManifestError extends Error {
|
|
56
|
+
readonly manifestPath: string;
|
|
57
|
+
|
|
58
|
+
constructor(manifestPath: string, message: string) {
|
|
59
|
+
super(`${manifestPath}: ${message}`);
|
|
60
|
+
this.name = "RepoManifestError";
|
|
61
|
+
this.manifestPath = manifestPath;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type LoadRepoManifestOptions = {
|
|
66
|
+
readonly warn?: (message: string) => void;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function isDirectory(path: string): boolean {
|
|
70
|
+
return existsSync(path) && statSync(path).isDirectory();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isWithinRoot(candidateReal: string, rootReal: string): boolean {
|
|
74
|
+
return candidateReal === rootReal || candidateReal.startsWith(rootReal + sep);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const GLOB_METACHARACTER_RE = /[*?[\]{}]/;
|
|
78
|
+
const GLOBSTAR_SEGMENT = "**";
|
|
79
|
+
|
|
80
|
+
// Only `*` and `?` are matched exactly; any other metacharacter matches every entry, which can over-reject but never lets a symlink slip through.
|
|
81
|
+
const UNSUPPORTED_GLOB_CHARACTER_RE = /[[\]{}]/;
|
|
82
|
+
|
|
83
|
+
function segmentMatcher(segment: string): RegExp {
|
|
84
|
+
if (UNSUPPORTED_GLOB_CHARACTER_RE.test(segment)) return /^.*$/;
|
|
85
|
+
const source = [...segment]
|
|
86
|
+
.map((char) => {
|
|
87
|
+
if (char === "*") return "[^/]*";
|
|
88
|
+
if (char === "?") return "[^/]";
|
|
89
|
+
return char.replace(/[.+^$()|\\]/g, "\\$&");
|
|
90
|
+
})
|
|
91
|
+
.join("");
|
|
92
|
+
return new RegExp(`^${source}$`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Walks the pattern segment by segment: a symlink can escape before the full path exists on disk (e.g. "linked/src" whose target has no "src"), and a wildcard segment ("packages/*/src") can match a symlinked directory. Segments below a "**" are not walked, so a symlink inside such a tree is the glob consumer's responsibility.
|
|
96
|
+
function assertSegmentsWithinRoot(
|
|
97
|
+
manifestPath: string,
|
|
98
|
+
rootReal: string,
|
|
99
|
+
pattern: string,
|
|
100
|
+
dirAbs: string,
|
|
101
|
+
segments: readonly string[],
|
|
102
|
+
): void {
|
|
103
|
+
for (const [index, segment] of segments.entries()) {
|
|
104
|
+
// skip: segments below "**" are the glob consumer's responsibility (see above)
|
|
105
|
+
if (segment === GLOBSTAR_SEGMENT) return;
|
|
106
|
+
if (!GLOB_METACHARACTER_RE.test(segment)) {
|
|
107
|
+
dirAbs = join(dirAbs, segment);
|
|
108
|
+
// skip: a path that does not exist yet cannot be a symlink out of the root
|
|
109
|
+
if (!existsSync(dirAbs)) return;
|
|
110
|
+
if (!isWithinRoot(realpathSync(dirAbs), rootReal)) {
|
|
111
|
+
throw new RepoManifestError(manifestPath, `"${pattern}" resolves outside the repo root`);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const matcher = segmentMatcher(segment);
|
|
116
|
+
// skip: a wildcard below a non-directory matches nothing
|
|
117
|
+
if (!isDirectory(dirAbs)) return;
|
|
118
|
+
const rest = segments.slice(index + 1);
|
|
119
|
+
for (const entry of readdirSync(dirAbs)) {
|
|
120
|
+
if (!matcher.test(entry)) continue;
|
|
121
|
+
const entryAbs = join(dirAbs, entry);
|
|
122
|
+
if (!existsSync(entryAbs)) continue;
|
|
123
|
+
if (!isWithinRoot(realpathSync(entryAbs), rootReal)) {
|
|
124
|
+
throw new RepoManifestError(manifestPath, `"${pattern}" resolves outside the repo root`);
|
|
125
|
+
}
|
|
126
|
+
if (rest.length > 0 && isDirectory(entryAbs)) {
|
|
127
|
+
assertSegmentsWithinRoot(manifestPath, rootReal, pattern, entryAbs, rest);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// The wildcard branch recursed into the remaining segments itself.
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assertPatternsResolveWithinRoot(
|
|
136
|
+
manifestPath: string,
|
|
137
|
+
rootAbs: string,
|
|
138
|
+
rootReal: string,
|
|
139
|
+
manifest: RepoManifest,
|
|
140
|
+
): void {
|
|
141
|
+
const patterns = [
|
|
142
|
+
...manifest.sourceRoots,
|
|
143
|
+
...manifest.testGlobs,
|
|
144
|
+
...(manifest.uiRoots ?? []),
|
|
145
|
+
...(manifest.excludes ?? []),
|
|
146
|
+
];
|
|
147
|
+
for (const value of patterns) {
|
|
148
|
+
assertSegmentsWithinRoot(manifestPath, rootReal, value, rootAbs, value.split("/"));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function loadManifestFile(
|
|
153
|
+
manifestPath: string,
|
|
154
|
+
rootAbs: string,
|
|
155
|
+
rootReal: string,
|
|
156
|
+
): LoadedRepoManifest {
|
|
157
|
+
const manifestReal = realpathSync(manifestPath);
|
|
158
|
+
if (!isWithinRoot(manifestReal, rootReal)) {
|
|
159
|
+
throw new RepoManifestError(manifestPath, "resolves outside the repo root");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const raw = readFileSync(manifestPath, "utf8");
|
|
163
|
+
let json: unknown;
|
|
164
|
+
try {
|
|
165
|
+
json = JSON.parse(raw);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
168
|
+
throw new RepoManifestError(manifestPath, `invalid JSON — ${message}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const result = repoManifestSchema.safeParse(json);
|
|
172
|
+
if (!result.success) {
|
|
173
|
+
const issues = result.error.issues
|
|
174
|
+
.map(
|
|
175
|
+
(issue) => `${issue.path.length > 0 ? issue.path.join(".") : "<root>"}: ${issue.message}`,
|
|
176
|
+
)
|
|
177
|
+
.join("; ");
|
|
178
|
+
throw new RepoManifestError(manifestPath, `invalid repo manifest — ${issues}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
assertPatternsResolveWithinRoot(manifestPath, rootAbs, rootReal, result.data);
|
|
182
|
+
|
|
183
|
+
return { manifest: result.data, source: "file", manifestPath };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function hasPackagesSrcLayout(rootAbs: string): boolean {
|
|
187
|
+
const packagesDir = join(rootAbs, "packages");
|
|
188
|
+
if (!isDirectory(packagesDir)) return false;
|
|
189
|
+
return readdirSync(packagesDir, { withFileTypes: true }).some(
|
|
190
|
+
(entry) => entry.isDirectory() && isDirectory(join(packagesDir, entry.name, "src")),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type DerivedLayout = { readonly layout: string; readonly manifest: RepoManifest };
|
|
195
|
+
|
|
196
|
+
function derivedLayoutFor(rootAbs: string): DerivedLayout | undefined {
|
|
197
|
+
if (hasPackagesSrcLayout(rootAbs)) {
|
|
198
|
+
return {
|
|
199
|
+
layout: "packages/*/src",
|
|
200
|
+
manifest: {
|
|
201
|
+
kind: "library",
|
|
202
|
+
sourceRoots: ["packages/*/src"],
|
|
203
|
+
testGlobs: ["packages/*/src/**/*.{test,integration}.{ts,tsx}"],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (isDirectory(join(rootAbs, "src"))) {
|
|
208
|
+
return {
|
|
209
|
+
layout: "src",
|
|
210
|
+
manifest: {
|
|
211
|
+
kind: "app",
|
|
212
|
+
sourceRoots: ["src"],
|
|
213
|
+
testGlobs: ["src/**/*.{test,integration}.{ts,tsx}"],
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function deriveManifest(
|
|
221
|
+
manifestPath: string,
|
|
222
|
+
rootAbs: string,
|
|
223
|
+
warn: (message: string) => void,
|
|
224
|
+
): LoadedRepoManifest {
|
|
225
|
+
const derived = derivedLayoutFor(rootAbs);
|
|
226
|
+
|
|
227
|
+
if (!derived) {
|
|
228
|
+
throw new RepoManifestError(
|
|
229
|
+
manifestPath,
|
|
230
|
+
"not found and no packages/*/src or src/ layout to derive one from",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
warn(
|
|
235
|
+
`${manifestPath} not found — derived "${derived.layout}" layout (kind "${derived.manifest.kind}"); ` +
|
|
236
|
+
"add a kumiko.json to declare the repo layout explicitly",
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
return { manifest: derived.manifest, source: "derived", manifestPath };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function defaultWarn(message: string): void {
|
|
243
|
+
console.warn(message);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function loadRepoManifest(
|
|
247
|
+
root: string,
|
|
248
|
+
options: LoadRepoManifestOptions = {},
|
|
249
|
+
): LoadedRepoManifest {
|
|
250
|
+
const rootAbs = resolve(root);
|
|
251
|
+
const manifestPath = join(rootAbs, REPO_MANIFEST_FILE);
|
|
252
|
+
|
|
253
|
+
if (!isDirectory(rootAbs)) {
|
|
254
|
+
throw new RepoManifestError(manifestPath, "repo root does not exist or is not a directory");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (existsSync(manifestPath)) {
|
|
258
|
+
return loadManifestFile(manifestPath, rootAbs, realpathSync(rootAbs));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return deriveManifest(manifestPath, rootAbs, options.warn ?? defaultWarn);
|
|
262
|
+
}
|
package/src/run-guards.ts
CHANGED
|
@@ -57,6 +57,7 @@ import { guard as tenantEscalation } from "./guard-tenant-escalation";
|
|
|
57
57
|
import { guard as testTemplateDrift } from "./guard-test-template-drift";
|
|
58
58
|
import { guard as testTimeouts } from "./guard-test-timeouts";
|
|
59
59
|
import { guard as textFieldStance } from "./guard-text-field-stance";
|
|
60
|
+
import { guard as unsafeAppendEvent } from "./guard-unsafe-append-event";
|
|
60
61
|
import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
|
|
61
62
|
import { guard as writeHandlerQns } from "./guard-write-handler-qns";
|
|
62
63
|
|
|
@@ -101,6 +102,7 @@ export const GUARDS = [
|
|
|
101
102
|
libTestCoverage,
|
|
102
103
|
testTimeouts,
|
|
103
104
|
testTemplateDrift,
|
|
105
|
+
unsafeAppendEvent,
|
|
104
106
|
];
|
|
105
107
|
|
|
106
108
|
export const GUARD_FLAGS = [
|