@cosmicdrift/kumiko-guards 0.374.0 → 0.377.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 +4 -3
- package/hooks/pre-push +1 -7
- 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 +38 -0
- package/src/guard-agent-docs-drift.ts +1 -1
- package/src/guard-comment-lang.ts +30 -1
- package/src/guard-direct-fetch.ts +2 -2
- 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-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
|
@@ -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 = [
|