@cosmicdrift/kumiko-guards 0.391.1 → 0.392.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/package.json +3 -3
- package/src/_lib/doc-status.ts +206 -0
- package/src/_lib/dockerfile.ts +116 -0
- package/src/_lib/german-test-title.ts +94 -0
- package/src/_lib/test-callee.ts +34 -0
- package/src/changes.json +18 -0
- package/src/guard-app-dockerfile.ts +216 -0
- package/src/guard-comment-issue-ref-only.ts +1 -1
- package/src/guard-comment-lang.ts +19 -2
- package/src/guard-cross-feature-imports.ts +2 -2
- package/src/guard-doc-status.ts +33 -0
- package/src/guard-ignore-tag-reason.ts +128 -0
- package/src/guard-test-timeouts.ts +6 -31
- package/src/guard-test-title.ts +159 -0
- package/src/run-guards.ts +4 -0
- package/src/run-repo-checks.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.392.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": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"LICENSE"
|
|
52
52
|
],
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.392.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@cosmicdrift/kumiko-framework": {
|
|
@@ -59,6 +59,6 @@
|
|
|
59
59
|
}
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-framework": "0.392.0"
|
|
63
63
|
}
|
|
64
64
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import type { RepoRoot } from "./roots";
|
|
4
|
+
|
|
5
|
+
export const DOC_STATUSES = [
|
|
6
|
+
"shipped",
|
|
7
|
+
"in-progress",
|
|
8
|
+
"blocked",
|
|
9
|
+
"parked",
|
|
10
|
+
"idea",
|
|
11
|
+
"unknown",
|
|
12
|
+
"superseded",
|
|
13
|
+
"reference",
|
|
14
|
+
] as const;
|
|
15
|
+
|
|
16
|
+
export type DocStatus = (typeof DOC_STATUSES)[number];
|
|
17
|
+
|
|
18
|
+
export type DocFrontmatter = {
|
|
19
|
+
readonly status: DocStatus;
|
|
20
|
+
readonly verified: string;
|
|
21
|
+
readonly issue?: string;
|
|
22
|
+
readonly depends?: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type StatusDoc = {
|
|
26
|
+
readonly relPath: string;
|
|
27
|
+
readonly frontmatter: DocFrontmatter | undefined;
|
|
28
|
+
readonly problems: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const DOC_DIRS: readonly string[] = [
|
|
32
|
+
"docs/plans",
|
|
33
|
+
"docs/runbooks",
|
|
34
|
+
"docs/migration",
|
|
35
|
+
"docs/reference",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// templates/ would leak frontmatter into the files generated from them,
|
|
39
|
+
// marketing/ is site copy, archive/ holds finished plans.
|
|
40
|
+
const EXCLUDED_SEGMENTS: readonly string[] = ["templates", "node_modules", "marketing", "archive"];
|
|
41
|
+
|
|
42
|
+
// Status overviews generated from the plan docs carry no frontmatter themselves.
|
|
43
|
+
const GENERATED_PLAN_INDEXES = new Set(["docs/plans/STATUS.md", "docs/plans/ROADMAP.md"]);
|
|
44
|
+
|
|
45
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
46
|
+
|
|
47
|
+
function isDocStatus(value: string): value is DocStatus {
|
|
48
|
+
return DOC_STATUSES.some((status) => status === value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseInlineList(raw: string | undefined): readonly string[] | undefined {
|
|
52
|
+
if (!raw) return undefined;
|
|
53
|
+
const inner = raw.startsWith("[") && raw.endsWith("]") ? raw.slice(1, -1) : raw;
|
|
54
|
+
const items = inner
|
|
55
|
+
.split(",")
|
|
56
|
+
.map((item) => item.trim().replace(/^["']|["']$/g, ""))
|
|
57
|
+
.filter((item) => item.length > 0);
|
|
58
|
+
return items.length > 0 ? items : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Anchored on purpose: an unanchored `word#digits` would turn a doc path such
|
|
62
|
+
// as `rfc#42.md` into a phantom issue reference.
|
|
63
|
+
export function isDependsRef(ref: string): boolean {
|
|
64
|
+
return /^(?:[\w.-]+\/)?[\w.-]+#\d+(?:\s+\(.*\))?$/.test(ref) || /^[\w.-]+:.+\.md$/.test(ref);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type ParsedFrontmatter = Pick<StatusDoc, "frontmatter" | "problems">;
|
|
68
|
+
|
|
69
|
+
function invalid(problem: string): ParsedFrontmatter {
|
|
70
|
+
return { frontmatter: undefined, problems: [problem] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readFields(
|
|
74
|
+
block: string,
|
|
75
|
+
): { fields: Map<string, string>; dependsItems?: string[] } | string {
|
|
76
|
+
const fields = new Map<string, string>();
|
|
77
|
+
let lastKey: string | undefined;
|
|
78
|
+
let dependsItems: string[] | undefined;
|
|
79
|
+
for (const line of block.split("\n")) {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (!trimmed) continue;
|
|
82
|
+
// Bullets before the `:` split, so `- repo:docs/x.md` never becomes a field.
|
|
83
|
+
if (trimmed.startsWith("- ")) {
|
|
84
|
+
if (lastKey !== "depends") {
|
|
85
|
+
return `block list item under '${lastKey ?? "<no field>"}', only 'depends' takes a list: ${trimmed}`;
|
|
86
|
+
}
|
|
87
|
+
dependsItems ??= [];
|
|
88
|
+
dependsItems.push(
|
|
89
|
+
trimmed
|
|
90
|
+
.slice(2)
|
|
91
|
+
.trim()
|
|
92
|
+
.replace(/^["']|["']$/g, ""),
|
|
93
|
+
);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const sep = line.indexOf(":");
|
|
97
|
+
if (sep < 0) return `frontmatter line without 'key: value': ${trimmed}`;
|
|
98
|
+
lastKey = line.slice(0, sep).trim();
|
|
99
|
+
fields.set(lastKey, line.slice(sep + 1).trim());
|
|
100
|
+
}
|
|
101
|
+
return { fields, dependsItems };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function statusDependentFieldProblems(status: string, fields: Map<string, string>): string[] {
|
|
105
|
+
const problems: string[] = [];
|
|
106
|
+
if (status === "shipped" && !fields.get("evidence")) {
|
|
107
|
+
problems.push(
|
|
108
|
+
"status 'shipped' needs evidence (PR, release or commit), otherwise use 'unknown'",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
if (status === "superseded" && !fields.get("superseded-by")) {
|
|
112
|
+
problems.push("status 'superseded' needs superseded-by");
|
|
113
|
+
}
|
|
114
|
+
if ((status === "in-progress" || status === "blocked") && !fields.get("next")) {
|
|
115
|
+
problems.push(`status '${status}' needs next (one line)`);
|
|
116
|
+
}
|
|
117
|
+
return problems;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function parseStatusFrontmatter(content: string): ParsedFrontmatter {
|
|
121
|
+
if (!content.startsWith("---\n"))
|
|
122
|
+
return invalid("no status frontmatter (file must start with '---')");
|
|
123
|
+
const end = content.indexOf("\n---", 4);
|
|
124
|
+
if (end < 0) return invalid("frontmatter is not closed (second '---' missing)");
|
|
125
|
+
const read = readFields(content.slice(4, end));
|
|
126
|
+
if (typeof read === "string") return invalid(read);
|
|
127
|
+
const { fields, dependsItems } = read;
|
|
128
|
+
|
|
129
|
+
const problems: string[] = [];
|
|
130
|
+
const status = fields.get("status") ?? "";
|
|
131
|
+
const verified = fields.get("verified") ?? "";
|
|
132
|
+
if (!isDocStatus(status)) {
|
|
133
|
+
problems.push(
|
|
134
|
+
`status '${status || "<empty>"}' is invalid, allowed: ${DOC_STATUSES.join(", ")}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!ISO_DATE.test(verified)) {
|
|
138
|
+
problems.push(`verified '${verified || "<empty>"}' is not an ISO date (YYYY-MM-DD)`);
|
|
139
|
+
}
|
|
140
|
+
problems.push(...statusDependentFieldProblems(status, fields));
|
|
141
|
+
if (problems.length > 0 || !isDocStatus(status)) return { frontmatter: undefined, problems };
|
|
142
|
+
return {
|
|
143
|
+
frontmatter: {
|
|
144
|
+
status,
|
|
145
|
+
verified,
|
|
146
|
+
issue: fields.get("issue"),
|
|
147
|
+
depends: dependsItems ?? parseInlineList(fields.get("depends")),
|
|
148
|
+
},
|
|
149
|
+
problems: [],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function statusDocHygieneWarnings(doc: StatusDoc): readonly string[] {
|
|
154
|
+
const fm = doc.frontmatter;
|
|
155
|
+
if (!fm) return [];
|
|
156
|
+
const warnings: string[] = [];
|
|
157
|
+
if ((fm.status === "in-progress" || fm.status === "blocked") && !fm.issue?.trim()) {
|
|
158
|
+
warnings.push(
|
|
159
|
+
`status '${fm.status}' without issue: an active spec names its issue in the frontmatter`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
(fm.status === "shipped" || fm.status === "superseded") &&
|
|
164
|
+
doc.relPath.startsWith("docs/plans/")
|
|
165
|
+
) {
|
|
166
|
+
warnings.push("shipped/superseded doc in docs/plans/: move it to docs/archive/plans/");
|
|
167
|
+
}
|
|
168
|
+
for (const ref of fm.depends ?? []) {
|
|
169
|
+
if (!isDependsRef(ref)) {
|
|
170
|
+
warnings.push(`depends '${ref}' is neither 'repo#issue' nor 'repo:path.md'`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return warnings;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function collectMarkdownFiles(dir: string, walkRoot: string, out: string[]): void {
|
|
177
|
+
for (const name of readdirSync(dir)) {
|
|
178
|
+
const abs = join(dir, name);
|
|
179
|
+
// Segments below the walk root only: a checkout under ~/marketing/ must not hide every doc.
|
|
180
|
+
if (
|
|
181
|
+
relative(walkRoot, abs)
|
|
182
|
+
.split("/")
|
|
183
|
+
.some((segment) => EXCLUDED_SEGMENTS.includes(segment))
|
|
184
|
+
) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (statSync(abs).isDirectory()) collectMarkdownFiles(abs, walkRoot, out);
|
|
188
|
+
else if (name.endsWith(".md")) out.push(abs);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function discoverStatusDocs(root: RepoRoot): StatusDoc[] {
|
|
193
|
+
const docs: StatusDoc[] = [];
|
|
194
|
+
for (const docDir of DOC_DIRS) {
|
|
195
|
+
const absDir = join(root.absPath, docDir);
|
|
196
|
+
if (!existsSync(absDir)) continue;
|
|
197
|
+
const files: string[] = [];
|
|
198
|
+
collectMarkdownFiles(absDir, absDir, files);
|
|
199
|
+
for (const absPath of files) {
|
|
200
|
+
const relPath = relative(root.absPath, absPath);
|
|
201
|
+
if (GENERATED_PLAN_INDEXES.has(relPath)) continue;
|
|
202
|
+
docs.push({ relPath, ...parseStatusFrontmatter(readFileSync(absPath, "utf-8")) });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return docs;
|
|
206
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
export type DockerStage = { readonly lines: readonly string[] };
|
|
2
|
+
|
|
3
|
+
export const NPM_AUTH_SECRET_ID = "npm_auth_token";
|
|
4
|
+
|
|
5
|
+
const FROM_RE = /^\s*FROM\s/i;
|
|
6
|
+
const BUN_INSTALL_RE = /^\s*RUN\s+.*\bbun\s+install\b/;
|
|
7
|
+
const COPY_FROM_RE = /^\s*COPY\s+--from=/;
|
|
8
|
+
const COPY_RE = /^\s*COPY\s+/;
|
|
9
|
+
const COPY_CONTEXT_RE = /^\s*COPY\s+(?:--\S+\s+)*\.\/?\s+\S+\s*$/;
|
|
10
|
+
const LEGACY_TOKEN_ARG_RE = /^\s*ARG\s+(\S+\s+)*NPM_AUTH_TOKEN(\s*=.*)?\s*$/;
|
|
11
|
+
const LEGACY_TOKEN_ENV_RE = /^\s*ENV\s+.*\bNPM_AUTH_TOKEN\b/;
|
|
12
|
+
|
|
13
|
+
// Docker line continuations (`\` at EOL) merged so a multi-line RUN/COPY
|
|
14
|
+
// still matches as one logical instruction.
|
|
15
|
+
export function joinContinuations(dockerfileText: string): string[] {
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
let buffer = "";
|
|
18
|
+
for (const raw of dockerfileText.split("\n")) {
|
|
19
|
+
const line = raw.replace(/\r$/, "");
|
|
20
|
+
buffer = buffer.length > 0 ? `${buffer} ${line}` : line;
|
|
21
|
+
if (/\\\s*$/.test(line)) {
|
|
22
|
+
buffer = buffer.replace(/\\\s*$/, "");
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
out.push(buffer);
|
|
26
|
+
buffer = "";
|
|
27
|
+
}
|
|
28
|
+
if (buffer.length > 0) out.push(buffer);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function splitStages(lines: readonly string[]): DockerStage[] {
|
|
33
|
+
const stages: DockerStage[] = [];
|
|
34
|
+
let current: string[] | undefined;
|
|
35
|
+
for (const line of lines) {
|
|
36
|
+
if (FROM_RE.test(line)) {
|
|
37
|
+
if (current) stages.push({ lines: current });
|
|
38
|
+
current = [line];
|
|
39
|
+
} else if (current) {
|
|
40
|
+
current.push(line);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (current) stages.push({ lines: current });
|
|
44
|
+
return stages;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function linesBeforeFirstFrom(lines: readonly string[]): readonly string[] {
|
|
48
|
+
const firstFrom = lines.findIndex((line) => FROM_RE.test(line));
|
|
49
|
+
return firstFrom === -1 ? lines : lines.slice(0, firstFrom);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The runtime stage's `bun install --production` never pulls devDependencies;
|
|
53
|
+
// only a full install needs registry auth for every private package.
|
|
54
|
+
export function isAuthedInstallLine(line: string): boolean {
|
|
55
|
+
return BUN_INSTALL_RE.test(line) && !/--production\b/.test(line);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Identified by what it does, not by its `AS <name>` label.
|
|
59
|
+
export function findBuildStage(stages: readonly DockerStage[]): DockerStage | undefined {
|
|
60
|
+
return stages.find((stage) => stage.lines.some(isAuthedInstallLine));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLegacyTokenArgOrEnv(line: string): boolean {
|
|
64
|
+
return LEGACY_TOKEN_ARG_RE.test(line) || LEGACY_TOKEN_ENV_RE.test(line);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Covers a direct `ARG GITHUB_TOKEN` too, not only the NPM_AUTH_TOKEN
|
|
68
|
+
// re-export: any build-arg carrying the token lands in the image history.
|
|
69
|
+
export function declaresRegistryTokenAsArgOrEnv(
|
|
70
|
+
line: string,
|
|
71
|
+
tokenVariables: ReadonlySet<string>,
|
|
72
|
+
): boolean {
|
|
73
|
+
if (isLegacyTokenArgOrEnv(line)) return true;
|
|
74
|
+
const declared = /^\s*(?:ARG|ENV)\s+(.*)$/.exec(line)?.[1];
|
|
75
|
+
if (declared === undefined) return false;
|
|
76
|
+
return [...tokenVariables].some((variable) =>
|
|
77
|
+
new RegExp(`(^|\\s)${variable}(=|\\s|$)`).test(declared),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function declaresLegacyTokenArg(line: string): boolean {
|
|
82
|
+
return LEGACY_TOKEN_ARG_RE.test(line);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A mount that only lands at /run/secrets passes BuildKit but 401s in the
|
|
86
|
+
// install: the RUN must expose the secret under a variable the registry
|
|
87
|
+
// config reads, via the mount's `env=` option or an inline assignment.
|
|
88
|
+
export function mountsNpmAuthSecret(line: string, tokenVariables: ReadonlySet<string>): boolean {
|
|
89
|
+
return [...line.matchAll(/--mount=(\S+)/g)].some((match) => {
|
|
90
|
+
const options = (match[1] ?? "").split(",");
|
|
91
|
+
if (!options.includes("type=secret") || !options.includes(`id=${NPM_AUTH_SECRET_ID}`)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
return [...tokenVariables].some(
|
|
95
|
+
(variable) => options.includes(`env=${variable}`) || new RegExp(`\\b${variable}=`).test(line),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function escapeRegExp(value: string): string {
|
|
101
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// `COPY . .` carries every file of the build context, so it counts for any name.
|
|
105
|
+
export function isCopiedBeforeInstall(stage: DockerStage | undefined, filename: string): boolean {
|
|
106
|
+
if (!stage) return false;
|
|
107
|
+
const installIdx = stage.lines.findIndex(isAuthedInstallLine);
|
|
108
|
+
const relevant = installIdx === -1 ? stage.lines : stage.lines.slice(0, installIdx);
|
|
109
|
+
const fileToken = new RegExp(`(^|\\s|/)${escapeRegExp(filename)}(\\s|$)`);
|
|
110
|
+
return relevant.some(
|
|
111
|
+
(line) =>
|
|
112
|
+
!COPY_FROM_RE.test(line) &&
|
|
113
|
+
COPY_RE.test(line) &&
|
|
114
|
+
(fileToken.test(line) || COPY_CONTEXT_RE.test(line)),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { isGermanComment } from "../guard-comment-lang";
|
|
2
|
+
|
|
3
|
+
// German words that essentially never occur in an English test title. Words that are also
|
|
4
|
+
// English (die, was, am, an, in, so, man, also, will, bin, war, hat, all) are left out on purpose.
|
|
5
|
+
const GERMAN_TITLE_WORDS: ReadonlySet<string> = new Set([
|
|
6
|
+
"der",
|
|
7
|
+
"das",
|
|
8
|
+
"den",
|
|
9
|
+
"dem",
|
|
10
|
+
"des",
|
|
11
|
+
"ein",
|
|
12
|
+
"eine",
|
|
13
|
+
"einen",
|
|
14
|
+
"einem",
|
|
15
|
+
"kann",
|
|
16
|
+
"wird",
|
|
17
|
+
"werden",
|
|
18
|
+
"nicht",
|
|
19
|
+
"wenn",
|
|
20
|
+
"oder",
|
|
21
|
+
"und",
|
|
22
|
+
"mit",
|
|
23
|
+
"ohne",
|
|
24
|
+
"bei",
|
|
25
|
+
"ist",
|
|
26
|
+
"sind",
|
|
27
|
+
"auf",
|
|
28
|
+
"zu",
|
|
29
|
+
"von",
|
|
30
|
+
"vom",
|
|
31
|
+
"zum",
|
|
32
|
+
"zur",
|
|
33
|
+
"im",
|
|
34
|
+
"vor",
|
|
35
|
+
"aus",
|
|
36
|
+
"als",
|
|
37
|
+
"sich",
|
|
38
|
+
"wie",
|
|
39
|
+
"auch",
|
|
40
|
+
"noch",
|
|
41
|
+
"nur",
|
|
42
|
+
"kein",
|
|
43
|
+
"keine",
|
|
44
|
+
"keinen",
|
|
45
|
+
"gibt",
|
|
46
|
+
"darf",
|
|
47
|
+
"muss",
|
|
48
|
+
"soll",
|
|
49
|
+
"liefert",
|
|
50
|
+
"wirft",
|
|
51
|
+
"setzt",
|
|
52
|
+
"zeigt",
|
|
53
|
+
"gewinnt",
|
|
54
|
+
"leer",
|
|
55
|
+
"leere",
|
|
56
|
+
"leerer",
|
|
57
|
+
"bleibt",
|
|
58
|
+
"macht",
|
|
59
|
+
"fehlt",
|
|
60
|
+
"schreibt",
|
|
61
|
+
"liest",
|
|
62
|
+
"sammelt",
|
|
63
|
+
"nennt",
|
|
64
|
+
"akzeptiert",
|
|
65
|
+
"ueber",
|
|
66
|
+
"uber",
|
|
67
|
+
"fuer",
|
|
68
|
+
"gegen",
|
|
69
|
+
"nach",
|
|
70
|
+
"dann",
|
|
71
|
+
"weil",
|
|
72
|
+
"statt",
|
|
73
|
+
"sowie",
|
|
74
|
+
"jede",
|
|
75
|
+
"jeder",
|
|
76
|
+
"jedes",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
const QUOTED_OR_CODE_RE = /`[^`]*`|"[^"]*"|'[^']*'/g;
|
|
80
|
+
const WORD_RE = /[\p{L}]+/gu;
|
|
81
|
+
|
|
82
|
+
function isUpperCaseAcronym(word: string): boolean {
|
|
83
|
+
return word.length > 1 && word === word.toUpperCase();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Test titles are short, so one unambiguous German word is enough, unlike comments which
|
|
87
|
+
// need an umlaut or two markers. Quoted text and code in backticks are fixture or identifier names.
|
|
88
|
+
export function isGermanTestTitle(title: string): boolean {
|
|
89
|
+
if (isGermanComment(title)) return true;
|
|
90
|
+
const prose = title.replace(QUOTED_OR_CODE_RE, " ");
|
|
91
|
+
return (prose.match(WORD_RE) ?? []).some(
|
|
92
|
+
(word) => !isUpperCaseAcronym(word) && GERMAN_TITLE_WORDS.has(word.toLowerCase()),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Node, type SourceFile } from "ts-morph";
|
|
2
|
+
|
|
3
|
+
const TEST_FRAMEWORK_MODULES: ReadonlySet<string> = new Set(["bun:test", "@playwright/test"]);
|
|
4
|
+
|
|
5
|
+
export const TEST_DECLARATION_CALLEE =
|
|
6
|
+
/^(test|it|describe)(\.(skip|only|todo|if|each|failing|concurrent|serial|skipIf|runIf|todoIf))*$/;
|
|
7
|
+
|
|
8
|
+
// Maps local names imported from the test framework to their exported name;
|
|
9
|
+
// "" marks a namespace import (`import * as bt`), whose member access is stripped.
|
|
10
|
+
export function testFrameworkAliases(sf: SourceFile): ReadonlyMap<string, string> {
|
|
11
|
+
const aliases = new Map<string, string>();
|
|
12
|
+
for (const decl of sf.getImportDeclarations()) {
|
|
13
|
+
if (!TEST_FRAMEWORK_MODULES.has(decl.getModuleSpecifierValue())) continue;
|
|
14
|
+
const namespaceImport = decl.getNamespaceImport();
|
|
15
|
+
if (namespaceImport !== undefined) aliases.set(namespaceImport.getText(), "");
|
|
16
|
+
for (const named of decl.getNamedImports()) {
|
|
17
|
+
const alias = named.getAliasNode();
|
|
18
|
+
if (alias !== undefined) aliases.set(alias.getText(), named.getName());
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return aliases;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function canonicalCalleeText(text: string, aliases: ReadonlyMap<string, string>): string {
|
|
25
|
+
const [head = "", ...rest] = text.split(".");
|
|
26
|
+
const imported = aliases.get(head);
|
|
27
|
+
if (imported === undefined) return text;
|
|
28
|
+
return (imported === "" ? rest : [imported, ...rest]).join(".");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// `test.each(rows)(…)` and `test.if(cond)(…)` call the result of a call.
|
|
32
|
+
export function declarationBaseCallee(callee: Node): Node {
|
|
33
|
+
return Node.isCallExpression(callee) ? declarationBaseCallee(callee.getExpression()) : callee;
|
|
34
|
+
}
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.392.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "app-dockerfile and doc-status run as public repo checks",
|
|
6
|
+
"detail": "kumiko-guards checks now includes app-dockerfile and doc-status, ported from the private infra guards package. app-dockerfile inspects deploy/Dockerfile: public/index.html or index.html must exist; with @cosmicdriftgamestudio/* dependencies the build stage copies a bunfig.toml or .npmrc scope entry before bun install and that RUN mounts the npm_auth_token BuildKit secret under the variable the registry config reads. The token as ARG/ENV (NPM_AUTH_TOKEN or the variable the registry config reads) is a warning for now and a violation next to a secret mount. Apps without private dependencies no longer need registry auth in the Dockerfile. doc-status requires valid status frontmatter for docs under docs/plans, runbooks, migration and reference; missing issue refs, finished plans outside the archive and malformed depends refs are warnings."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.392.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "comment-lang scans every source root of the framework repo",
|
|
12
|
+
"detail": "The comment-language guard no longer limits the framework repo to packages/*/src and now also scans samples, scripts, bin, packages/*/bin, apps/*/src and packages/renderer/e2e. App and library repos are unchanged, they already scanned all their source roots."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.392.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "New test-title and ignore-tag-reason guards, fewer comment-language false positives",
|
|
18
|
+
"detail": "test-title scans test, it and describe titles (including each/skip/only chains) for German text (an umlaut, two German function words, or one unambiguous German word) and issue references. ignore-tag-reason requires a kumiko-lint-ignore reason of at least three words that is not generic. Both start with a baseline of 0 and warn only in repos without a baseline. The comment-language guard now reports a comment only when a lowercase word contains an umlaut, an umlaut appears together with a German function word, or at least two German markers appear, so proper nouns with umlauts pass."
|
|
19
|
+
},
|
|
2
20
|
{
|
|
3
21
|
"version": "0.390.0",
|
|
4
22
|
"type": "improvement",
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* An app's `deploy/Dockerfile` must reach the private @cosmicdriftgamestudio registry through a
|
|
4
|
+
* BuildKit secret, never through an NPM_AUTH_TOKEN build-arg that lands in the image history.
|
|
5
|
+
*
|
|
6
|
+
* Per repo with `deploy/Dockerfile`:
|
|
7
|
+
* - `public/index.html` or `index.html` exists.
|
|
8
|
+
* - With @cosmicdriftgamestudio/* dependencies: bunfig.toml (`[install.scopes]`) or .npmrc
|
|
9
|
+
* (registry line plus `_authToken`) for the scope is copied into the build stage before its
|
|
10
|
+
* `bun install`, and that RUN mounts the `npm_auth_token` secret under a variable the
|
|
11
|
+
* registry config reads (`env=` mount option or inline assignment).
|
|
12
|
+
* - The token as ARG/ENV (NPM_AUTH_TOKEN or the variable the registry config reads) is a
|
|
13
|
+
* warning; next to a secret mount it is a violation.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
18
|
+
import {
|
|
19
|
+
type DockerStage,
|
|
20
|
+
declaresLegacyTokenArg,
|
|
21
|
+
declaresRegistryTokenAsArgOrEnv,
|
|
22
|
+
findBuildStage,
|
|
23
|
+
isAuthedInstallLine,
|
|
24
|
+
isCopiedBeforeInstall,
|
|
25
|
+
joinContinuations,
|
|
26
|
+
linesBeforeFirstFrom,
|
|
27
|
+
mountsNpmAuthSecret,
|
|
28
|
+
NPM_AUTH_SECRET_ID,
|
|
29
|
+
splitStages,
|
|
30
|
+
} from "./_lib/dockerfile";
|
|
31
|
+
import {
|
|
32
|
+
type GuardViolation,
|
|
33
|
+
type RepoCheck,
|
|
34
|
+
reportResults,
|
|
35
|
+
runRepoChecks,
|
|
36
|
+
} from "./_lib/guard-kit";
|
|
37
|
+
import type { RepoRoot } from "./_lib/roots";
|
|
38
|
+
|
|
39
|
+
const DOCKERFILE_REL = "deploy/Dockerfile";
|
|
40
|
+
const BUNFIG_REL = "bunfig.toml";
|
|
41
|
+
const NPMRC_REL = ".npmrc";
|
|
42
|
+
const PRIVATE_SCOPE = "@cosmicdriftgamestudio";
|
|
43
|
+
const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "optionalDependencies"] as const;
|
|
44
|
+
const SHELL_VARIABLE_RE = /\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g;
|
|
45
|
+
|
|
46
|
+
export type AppDockerfileInput = {
|
|
47
|
+
readonly dockerfileText: string;
|
|
48
|
+
readonly hasPrivateScopeDependency: boolean;
|
|
49
|
+
readonly bunfigText: string | undefined;
|
|
50
|
+
readonly npmrcText: string | undefined;
|
|
51
|
+
readonly hasIndexHtml: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type AppDockerfileFindings = {
|
|
55
|
+
readonly violations: readonly string[];
|
|
56
|
+
readonly warnings: readonly string[];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function hasBunfigScope(bunfigText: string | undefined): boolean {
|
|
60
|
+
if (bunfigText === undefined) return false;
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = Bun.TOML.parse(bunfigText);
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const install = isPlainObject(parsed) ? parsed["install"] : undefined;
|
|
68
|
+
const scopes = isPlainObject(install) ? install["scopes"] : undefined;
|
|
69
|
+
return isPlainObject(scopes) && PRIVATE_SCOPE in scopes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A registry line without an auth token routes the scope to GitHub Packages
|
|
73
|
+
// without credentials, which 401s the same way a missing line does.
|
|
74
|
+
function hasNpmrcScope(npmrcText: string | undefined): boolean {
|
|
75
|
+
if (npmrcText === undefined) return false;
|
|
76
|
+
return (
|
|
77
|
+
new RegExp(`^\\s*${PRIVATE_SCOPE}:registry=`, "m").test(npmrcText) &&
|
|
78
|
+
/^\s*\/\/npm\.pkg\.github\.com\/:_authToken=/m.test(npmrcText)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tokenVariablesOf(...configTexts: readonly (string | undefined)[]): Set<string> {
|
|
83
|
+
const variables = new Set<string>();
|
|
84
|
+
for (const text of configTexts) {
|
|
85
|
+
for (const match of (text ?? "").matchAll(SHELL_VARIABLE_RE)) {
|
|
86
|
+
if (match[1]) variables.add(match[1]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return variables;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function registryAuthViolation(input: AppDockerfileInput, stage: DockerStage): string | undefined {
|
|
93
|
+
const bunfigReaches =
|
|
94
|
+
hasBunfigScope(input.bunfigText) && isCopiedBeforeInstall(stage, BUNFIG_REL);
|
|
95
|
+
const npmrcReaches = hasNpmrcScope(input.npmrcText) && isCopiedBeforeInstall(stage, NPMRC_REL);
|
|
96
|
+
if (bunfigReaches || npmrcReaches) return undefined;
|
|
97
|
+
return (
|
|
98
|
+
`no registry auth for "${PRIVATE_SCOPE}" reaches the build stage before \`bun install\`: ` +
|
|
99
|
+
`copy a bunfig.toml with an [install.scopes] entry (preferred) or an .npmrc with a ` +
|
|
100
|
+
`"${PRIVATE_SCOPE}:registry=" line and a "npm.pkg.github.com" _authToken line before the install`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function legacyArgPathIsComplete(lines: readonly string[], stage: DockerStage): boolean {
|
|
105
|
+
const installIdx = stage.lines.findIndex(isAuthedInstallLine);
|
|
106
|
+
const stageArgIdx = stage.lines.findIndex(declaresLegacyTokenArg);
|
|
107
|
+
return (
|
|
108
|
+
linesBeforeFirstFrom(lines).some(declaresLegacyTokenArg) &&
|
|
109
|
+
stageArgIdx !== -1 &&
|
|
110
|
+
stageArgIdx < installIdx
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function inspectAppDockerfile(input: AppDockerfileInput): AppDockerfileFindings {
|
|
115
|
+
const violations: string[] = [];
|
|
116
|
+
const warnings: string[] = [];
|
|
117
|
+
if (!input.hasIndexHtml) violations.push("neither public/index.html nor index.html exists");
|
|
118
|
+
|
|
119
|
+
const lines = joinContinuations(input.dockerfileText);
|
|
120
|
+
const tokenVariables = tokenVariablesOf(input.bunfigText, input.npmrcText);
|
|
121
|
+
const usesLegacyTokenPath = lines.some((line) =>
|
|
122
|
+
declaresRegistryTokenAsArgOrEnv(line, tokenVariables),
|
|
123
|
+
);
|
|
124
|
+
const buildStage = findBuildStage(splitStages(lines));
|
|
125
|
+
const installLine = buildStage?.lines.find(isAuthedInstallLine);
|
|
126
|
+
const mountsSecret =
|
|
127
|
+
installLine !== undefined && mountsNpmAuthSecret(installLine, tokenVariables);
|
|
128
|
+
|
|
129
|
+
if (mountsSecret && usesLegacyTokenPath) {
|
|
130
|
+
violations.push(
|
|
131
|
+
`the install mounts the ${NPM_AUTH_SECRET_ID} secret but the registry token is still declared as ARG/ENV, which bakes it into the image history`,
|
|
132
|
+
);
|
|
133
|
+
} else if (usesLegacyTokenPath) {
|
|
134
|
+
warnings.push(
|
|
135
|
+
`the registry token as ARG/ENV lands in the image history; mount the ${NPM_AUTH_SECRET_ID} BuildKit secret on the install RUN instead (managed Dockerfile template)`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!input.hasPrivateScopeDependency) return { violations, warnings };
|
|
140
|
+
if (buildStage === undefined) {
|
|
141
|
+
violations.push(
|
|
142
|
+
"no stage runs a `bun install` without --production, so the private registry auth cannot be verified",
|
|
143
|
+
);
|
|
144
|
+
return { violations, warnings };
|
|
145
|
+
}
|
|
146
|
+
const authViolation = registryAuthViolation(input, buildStage);
|
|
147
|
+
if (authViolation !== undefined) violations.push(authViolation);
|
|
148
|
+
if (!mountsSecret && !legacyArgPathIsComplete(lines, buildStage)) {
|
|
149
|
+
const readers =
|
|
150
|
+
tokenVariables.size > 0
|
|
151
|
+
? [...tokenVariables].join(" or ")
|
|
152
|
+
: "the variable bunfig.toml/.npmrc reads";
|
|
153
|
+
violations.push(
|
|
154
|
+
`the build-stage \`bun install\` does not mount the registry token: add --mount=type=secret,id=${NPM_AUTH_SECRET_ID},env=<var> with <var> = ${readers}`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return { violations, warnings };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readOptional(path: string): string | undefined {
|
|
161
|
+
return existsSync(path) ? readFileSync(path, "utf-8") : undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function hasPrivateScopeDependency(repoPath: string): boolean {
|
|
165
|
+
const manifestText = readOptional(join(repoPath, "package.json"));
|
|
166
|
+
if (manifestText === undefined) return false;
|
|
167
|
+
let manifest: unknown;
|
|
168
|
+
try {
|
|
169
|
+
manifest = JSON.parse(manifestText);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
throw new Error(`${join(repoPath, "package.json")}: invalid JSON`, { cause: error });
|
|
172
|
+
}
|
|
173
|
+
if (!isPlainObject(manifest)) return false;
|
|
174
|
+
return DEPENDENCY_FIELDS.some((field) => {
|
|
175
|
+
const deps = manifest[field];
|
|
176
|
+
return (
|
|
177
|
+
isPlainObject(deps) && Object.keys(deps).some((name) => name.startsWith(`${PRIVATE_SCOPE}/`))
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function inspectRepo(root: RepoRoot): AppDockerfileFindings {
|
|
183
|
+
return inspectAppDockerfile({
|
|
184
|
+
dockerfileText: readFileSync(join(root.absPath, DOCKERFILE_REL), "utf-8"),
|
|
185
|
+
hasPrivateScopeDependency: hasPrivateScopeDependency(root.absPath),
|
|
186
|
+
bunfigText: readOptional(join(root.absPath, BUNFIG_REL)),
|
|
187
|
+
npmrcText: readOptional(join(root.absPath, NPMRC_REL)),
|
|
188
|
+
hasIndexHtml:
|
|
189
|
+
existsSync(join(root.absPath, "public/index.html")) ||
|
|
190
|
+
existsSync(join(root.absPath, "index.html")),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function asViolation(message: string): GuardViolation {
|
|
195
|
+
return { file: DOCKERFILE_REL, line: 1, message };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export const check: RepoCheck = {
|
|
199
|
+
name: "app-dockerfile",
|
|
200
|
+
hint: "Regenerate deploy/Dockerfile from the managed template and pass the registry token as the npm_auth_token BuildKit secret.",
|
|
201
|
+
run(roots) {
|
|
202
|
+
const appRoots = roots.filter((root) => existsSync(join(root.absPath, DOCKERFILE_REL)));
|
|
203
|
+
const findings = appRoots.map(inspectRepo);
|
|
204
|
+
return {
|
|
205
|
+
violations: findings.flatMap((finding) => finding.violations.map(asViolation)),
|
|
206
|
+
warnings: findings.flatMap((finding) => finding.warnings.map(asViolation)),
|
|
207
|
+
matchedFiles: appRoots.length,
|
|
208
|
+
notApplicable: appRoots.length === 0,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
if (import.meta.main) {
|
|
214
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
215
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
216
|
+
}
|
|
@@ -41,7 +41,7 @@ const SCAN: ScanSpec = {
|
|
|
41
41
|
const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
|
|
42
42
|
|
|
43
43
|
// `fw#12`, `#1234`, `kumiko-framework#3`, `oven-sh/bun#5`; `'` is an HTML entity.
|
|
44
|
-
const REFERENCE_RE = /(?<!&)[\w./-]*#\d+(?![\w])/g;
|
|
44
|
+
export const REFERENCE_RE = /(?<!&)[\w./-]*#\d+(?![\w])/g;
|
|
45
45
|
// Not preceded by a path/word character and not followed by `/`, `-` or `.ext`, so file names such
|
|
46
46
|
// as `round2.ts` or `phase1.sql` are not planning labels, while a sentence-final "Phase 2." is.
|
|
47
47
|
const PLANNING_WORD_RE =
|
|
@@ -31,7 +31,6 @@ const SCAN: ScanSpec = {
|
|
|
31
31
|
scope: "source",
|
|
32
32
|
kinds: ALL_REPO_KINDS,
|
|
33
33
|
extensions: ["ts", "tsx"],
|
|
34
|
-
frameworkWithin: ["packages/*/src/**"],
|
|
35
34
|
};
|
|
36
35
|
|
|
37
36
|
const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
|
|
@@ -41,11 +40,29 @@ const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
|
|
|
41
40
|
// not enough.
|
|
42
41
|
const DE_MARKER_RE =
|
|
43
42
|
/\b(und|oder|für|wenn|nicht|dann|auch|noch|wird|sind|mit|zum|zur|bei|nach|ohne|über|kein|eine|einen|dieser|diese)\b/g;
|
|
43
|
+
// Function words that never occur in English but are too rare in German
|
|
44
|
+
// comments to justify a hit on their own; they only count next to an umlaut.
|
|
45
|
+
const DE_WEAK_MARKER_RE =
|
|
46
|
+
/\b(ob|der|dessen|ein|jetzt|nur|sobald|schon|falls|sowie|bzw|beim|hier|keine|bewusst|zwei)\b/g;
|
|
44
47
|
const MIN_DE_MARKER_HITS = 2;
|
|
45
48
|
const UMLAUT_RE = /[äöüÄÖÜß]/;
|
|
49
|
+
const WORD_RE = /\p{L}+/gu;
|
|
50
|
+
const LOWER_CASE_WORD_RE = /^\p{Ll}/u;
|
|
51
|
+
|
|
52
|
+
// A capitalised word with an umlaut is a proper noun or a fixture name ("Müller GmbH",
|
|
53
|
+
// "Zürich"), so only a lower-case word carries the umlaut signal alone.
|
|
54
|
+
function hasLowerCaseUmlautWord(text: string): boolean {
|
|
55
|
+
return (text.match(WORD_RE) ?? []).some(
|
|
56
|
+
(word) => LOWER_CASE_WORD_RE.test(word) && UMLAUT_RE.test(word),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
46
59
|
|
|
47
60
|
export function isGermanComment(text: string): boolean {
|
|
48
|
-
|
|
61
|
+
const markerHits = text.match(DE_MARKER_RE)?.length ?? 0;
|
|
62
|
+
if (markerHits >= MIN_DE_MARKER_HITS) return true;
|
|
63
|
+
if (hasLowerCaseUmlautWord(text)) return true;
|
|
64
|
+
if (!UMLAUT_RE.test(text)) return false;
|
|
65
|
+
return markerHits + (text.match(DE_WEAK_MARKER_RE)?.length ?? 0) >= 1;
|
|
49
66
|
}
|
|
50
67
|
|
|
51
68
|
export type Site = {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* single-feature samples, framework internals) have no feature boundary
|
|
29
29
|
* and are skipped.
|
|
30
30
|
*
|
|
31
|
-
* Escape hatch: `// kumiko-lint-ignore cross-feature-import
|
|
31
|
+
* Escape hatch: `// kumiko-lint-ignore cross-feature-import <reason>`
|
|
32
32
|
* on the same line as the import, or on the line directly above.
|
|
33
33
|
*
|
|
34
34
|
* Usage:
|
|
@@ -220,7 +220,7 @@ function findCrossFeatureViolations(sf: SourceFile): Omit<Violation, "file">[] {
|
|
|
220
220
|
export const guard: AstGuard = {
|
|
221
221
|
name: "Cross-Feature-Import Guard",
|
|
222
222
|
scan: SCAN,
|
|
223
|
-
hint: `Import from the feature's barrel ("../<feature>") instead of its internals. Escape hatch: "// ${IGNORE_TAG}
|
|
223
|
+
hint: `Import from the feature's barrel ("../<feature>") instead of its internals. Escape hatch: "// ${IGNORE_TAG} <reason>" on the import line.`,
|
|
224
224
|
run(files) {
|
|
225
225
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
226
226
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Every doc under docs/{plans,runbooks,migration,reference} needs valid status frontmatter.
|
|
4
|
+
*
|
|
5
|
+
* status and verified are required; shipped needs evidence, superseded needs superseded-by,
|
|
6
|
+
* in-progress and blocked need next. Missing issue refs, finished plans outside the archive and
|
|
7
|
+
* malformed depends refs are warnings.
|
|
8
|
+
*/
|
|
9
|
+
import { discoverStatusDocs, statusDocHygieneWarnings } from "./_lib/doc-status";
|
|
10
|
+
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
11
|
+
|
|
12
|
+
export const check: RepoCheck = {
|
|
13
|
+
name: "doc-status",
|
|
14
|
+
hint: "Frontmatter schema: status + verified (YYYY-MM-DD); shipped → evidence, superseded → superseded-by, in-progress|blocked → next.",
|
|
15
|
+
run(roots) {
|
|
16
|
+
const docs = roots.flatMap((root) => discoverStatusDocs(root));
|
|
17
|
+
return {
|
|
18
|
+
violations: docs.flatMap((doc) =>
|
|
19
|
+
doc.problems.map((message) => ({ file: doc.relPath, line: 1, message })),
|
|
20
|
+
),
|
|
21
|
+
warnings: docs.flatMap((doc) =>
|
|
22
|
+
statusDocHygieneWarnings(doc).map((message) => ({ file: doc.relPath, line: 1, message })),
|
|
23
|
+
),
|
|
24
|
+
matchedFiles: docs.length,
|
|
25
|
+
notApplicable: docs.length === 0,
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
if (import.meta.main) {
|
|
31
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
32
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
33
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Ignore-Tag-Reason Guard: every `kumiko-lint-ignore <slug> <reason>` comment states a real reason.
|
|
4
|
+
* A missing reason, a generic one (`todo`, `legacy`, ...) or one shorter than three words ("invite-flow")
|
|
5
|
+
* only silences a guard without telling the next reader why the exception is safe. Existing hits are
|
|
6
|
+
* frozen per file in `.kumiko-ignore-tag-reason-baseline.json` and may not grow; without a baseline
|
|
7
|
+
* file (consumer repos) the guard only warns. Documentation placeholders (`<reason>`) never count.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import type { SourceFile } from "ts-morph";
|
|
12
|
+
import { scanCommentTrivia } from "./_lib/comment-trivia";
|
|
13
|
+
import { isGenericReason } from "./_lib/generic-reason";
|
|
14
|
+
import {
|
|
15
|
+
ALL_REPO_KINDS,
|
|
16
|
+
type AstGuard,
|
|
17
|
+
baselineRatchet,
|
|
18
|
+
buildSharedProject,
|
|
19
|
+
filesForGuard,
|
|
20
|
+
isLocalFinding,
|
|
21
|
+
relFromRepoRoot,
|
|
22
|
+
runStandalone,
|
|
23
|
+
type ScanSpec,
|
|
24
|
+
} from "./_lib/guard-kit";
|
|
25
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
26
|
+
|
|
27
|
+
const ROOT = process.cwd();
|
|
28
|
+
const GUARD_NAME = "ignore-tag-reason";
|
|
29
|
+
const MIN_REASON_WORDS = 3;
|
|
30
|
+
|
|
31
|
+
const SCAN: ScanSpec = {
|
|
32
|
+
scope: "source+tests",
|
|
33
|
+
kinds: ALL_REPO_KINDS,
|
|
34
|
+
extensions: ["ts", "tsx"],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
|
|
38
|
+
const COMMENT_MARKER_PREFIX_RE = /^\s*(?:\/\/+|\/\*+|\*+)?\s*/;
|
|
39
|
+
const COMMENT_CLOSE_SUFFIX_RE = /\s*\*\/\s*\}?\s*$/;
|
|
40
|
+
const TAG_LINE_RE = /^kumiko-lint-ignore\s+(\S+)(?:\s+(.*))?$/;
|
|
41
|
+
|
|
42
|
+
export type WeakIgnoreReason = {
|
|
43
|
+
readonly line: number;
|
|
44
|
+
readonly slug: string;
|
|
45
|
+
readonly reason: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function isPlaceholder(text: string): boolean {
|
|
49
|
+
return text.startsWith("<");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isWeakReason(reason: string): boolean {
|
|
53
|
+
return isGenericReason(reason) || reason.split(/\s+/).filter(Boolean).length < MIN_REASON_WORDS;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function findWeakIgnoreReasons(fullText: string): WeakIgnoreReason[] {
|
|
57
|
+
const sites: WeakIgnoreReason[] = [];
|
|
58
|
+
for (const comment of scanCommentTrivia(fullText)) {
|
|
59
|
+
const firstLine = fullText.slice(0, comment.start).split("\n").length;
|
|
60
|
+
for (const [offset, rawLine] of comment.text.split("\n").entries()) {
|
|
61
|
+
const body = rawLine
|
|
62
|
+
.replace(COMMENT_MARKER_PREFIX_RE, "")
|
|
63
|
+
.replace(COMMENT_CLOSE_SUFFIX_RE, "");
|
|
64
|
+
const match = TAG_LINE_RE.exec(body.trim());
|
|
65
|
+
const slug = match?.[1] ?? "";
|
|
66
|
+
const reason = (match?.[2] ?? "").trim();
|
|
67
|
+
const isCheckable = match !== null && !isPlaceholder(slug) && !isPlaceholder(reason);
|
|
68
|
+
if (isCheckable && isWeakReason(reason)) {
|
|
69
|
+
sites.push({ line: firstLine + offset, slug, reason });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return sites;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function weakIgnoreReasonCounts(
|
|
77
|
+
files: readonly SourceFile[],
|
|
78
|
+
roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
|
|
79
|
+
): { counts: Record<string, number>; firstLine: Record<string, number> } {
|
|
80
|
+
const counts: Record<string, number> = {};
|
|
81
|
+
const firstLine: Record<string, number> = {};
|
|
82
|
+
for (const sf of files) {
|
|
83
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
84
|
+
const sites = findWeakIgnoreReasons(sf.getFullText());
|
|
85
|
+
const first = sites[0];
|
|
86
|
+
if (first === undefined) continue;
|
|
87
|
+
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
88
|
+
if (!isLocalFinding({ file })) continue;
|
|
89
|
+
counts[file] = sites.length;
|
|
90
|
+
firstLine[file] = first.line;
|
|
91
|
+
}
|
|
92
|
+
return { counts, firstLine };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const weakReasonBaseline = baselineRatchet({
|
|
96
|
+
file: path.join(ROOT, ".kumiko-ignore-tag-reason-baseline.json"),
|
|
97
|
+
formatVersion: 1,
|
|
98
|
+
unit: "ignore tag(s) without a real reason",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const REMEDIATION = `Write why the exception is safe in at least ${MIN_REASON_WORDS} words after the slug: // kumiko-lint-ignore <slug> <reason>.`;
|
|
102
|
+
|
|
103
|
+
export const guard: AstGuard = {
|
|
104
|
+
name: GUARD_NAME,
|
|
105
|
+
scan: SCAN,
|
|
106
|
+
hint: `after fixing hits: \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\``,
|
|
107
|
+
run: (files, roots = resolveRepoRoots()) => {
|
|
108
|
+
const { counts, firstLine } = weakIgnoreReasonCounts(files, roots);
|
|
109
|
+
return {
|
|
110
|
+
violations: weakReasonBaseline.check(counts, REMEDIATION, {
|
|
111
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\` once.`,
|
|
112
|
+
resolveLine: (file) => firstLine[file] ?? 1,
|
|
113
|
+
}),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
writeBaseline: (files, roots = resolveRepoRoots()) => {
|
|
117
|
+
weakReasonBaseline.write(weakIgnoreReasonCounts(files, roots).counts);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if (import.meta.main) {
|
|
122
|
+
if (process.argv.slice(2).includes("--write-baseline")) {
|
|
123
|
+
const project = buildSharedProject([guard]);
|
|
124
|
+
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
runStandalone(guard);
|
|
128
|
+
}
|
|
@@ -43,6 +43,12 @@ import {
|
|
|
43
43
|
type ScanSpec,
|
|
44
44
|
} from "./_lib/guard-kit";
|
|
45
45
|
import { type RepoRoot, resolveRepoRoots } from "./_lib/roots";
|
|
46
|
+
import {
|
|
47
|
+
canonicalCalleeText,
|
|
48
|
+
declarationBaseCallee,
|
|
49
|
+
TEST_DECLARATION_CALLEE,
|
|
50
|
+
testFrameworkAliases,
|
|
51
|
+
} from "./_lib/test-callee";
|
|
46
52
|
|
|
47
53
|
// Playwright dirs sit outside every manifest's sourceRoots/testGlobs, and
|
|
48
54
|
// scenario helpers next to the specs (not just *.spec.ts) carry the timeouts too.
|
|
@@ -75,9 +81,6 @@ const TIMEOUT_RAISING_CALLEES: ReadonlySet<string> = new Set(["test.setTimeout",
|
|
|
75
81
|
const SET_TIMEOUT_CALLEE = /(^|\.)setTimeout$/;
|
|
76
82
|
const SET_DEFAULT_TIMEOUT_CALLEE = /(^|\.)setDefaultTimeout$/;
|
|
77
83
|
const DESCRIBE_CONFIGURE_CALLEE = /(^|\.)describe\.configure$/;
|
|
78
|
-
const TEST_FRAMEWORK_MODULES: ReadonlySet<string> = new Set(["bun:test", "@playwright/test"]);
|
|
79
|
-
const TEST_DECLARATION_CALLEE =
|
|
80
|
-
/^(test|it|describe)(\.(skip|only|todo|if|each|failing|concurrent|serial|skipIf|runIf|todoIf))*$/;
|
|
81
84
|
|
|
82
85
|
const HOOK_DECLARATION_CALLEE = /^(beforeAll|beforeEach|afterAll|afterEach)$/;
|
|
83
86
|
|
|
@@ -153,34 +156,6 @@ function hasTimeoutProperty(node: Node | undefined): boolean {
|
|
|
153
156
|
);
|
|
154
157
|
}
|
|
155
158
|
|
|
156
|
-
// Maps local names imported from the test framework to their exported name;
|
|
157
|
-
// "" marks a namespace import (`import * as bt`), whose member access is stripped.
|
|
158
|
-
function testFrameworkAliases(sf: SourceFile): ReadonlyMap<string, string> {
|
|
159
|
-
const aliases = new Map<string, string>();
|
|
160
|
-
for (const decl of sf.getImportDeclarations()) {
|
|
161
|
-
if (!TEST_FRAMEWORK_MODULES.has(decl.getModuleSpecifierValue())) continue;
|
|
162
|
-
const namespaceImport = decl.getNamespaceImport();
|
|
163
|
-
if (namespaceImport !== undefined) aliases.set(namespaceImport.getText(), "");
|
|
164
|
-
for (const named of decl.getNamedImports()) {
|
|
165
|
-
const alias = named.getAliasNode();
|
|
166
|
-
if (alias !== undefined) aliases.set(alias.getText(), named.getName());
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return aliases;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function canonicalCalleeText(text: string, aliases: ReadonlyMap<string, string>): string {
|
|
173
|
-
const [head = "", ...rest] = text.split(".");
|
|
174
|
-
const imported = aliases.get(head);
|
|
175
|
-
if (imported === undefined) return text;
|
|
176
|
-
return (imported === "" ? rest : [imported, ...rest]).join(".");
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// `test.each(rows)(…)` and `test.if(cond)(…)` call the result of a call.
|
|
180
|
-
function declarationBaseCallee(callee: Node): Node {
|
|
181
|
-
return Node.isCallExpression(callee) ? declarationBaseCallee(callee.getExpression()) : callee;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
159
|
// An identifier can be the Playwright test body (`test(title, details, body)`) or an options
|
|
185
160
|
// variable like `{ retry: 2 }`, so only its type decides. Every other non-function expression
|
|
186
161
|
// (conditional, member access, call) is treated as a timeout so a union or untyped value cannot hide one.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Test-title guard: the title of a `test`/`it`/`describe` (including `.each`, `.skip`, `.only`,
|
|
4
|
+
* `.todo` and `.if` chains) is English and carries no issue or PR reference. A German title or one
|
|
5
|
+
* ending in `(#1234)` ages into noise: the number belongs in the commit message, the title has to
|
|
6
|
+
* state the behaviour. Existing hits are frozen per file in `.kumiko-test-title-baseline.json` and
|
|
7
|
+
* may not grow; without a baseline file (consumer repos) the guard only warns.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { type CallExpression, Node, type SourceFile, SyntaxKind } from "ts-morph";
|
|
12
|
+
import { isGermanTestTitle } from "./_lib/german-test-title";
|
|
13
|
+
import {
|
|
14
|
+
ALL_REPO_KINDS,
|
|
15
|
+
type AstGuard,
|
|
16
|
+
baselineRatchet,
|
|
17
|
+
buildSharedProject,
|
|
18
|
+
filesForGuard,
|
|
19
|
+
isLocalFinding,
|
|
20
|
+
relFromRepoRoot,
|
|
21
|
+
runStandalone,
|
|
22
|
+
type ScanSpec,
|
|
23
|
+
} from "./_lib/guard-kit";
|
|
24
|
+
import { hasIgnoreTag } from "./_lib/ignore-tag";
|
|
25
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
26
|
+
import {
|
|
27
|
+
canonicalCalleeText,
|
|
28
|
+
declarationBaseCallee,
|
|
29
|
+
TEST_DECLARATION_CALLEE,
|
|
30
|
+
testFrameworkAliases,
|
|
31
|
+
} from "./_lib/test-callee";
|
|
32
|
+
import { REFERENCE_RE } from "./guard-comment-issue-ref-only";
|
|
33
|
+
|
|
34
|
+
const ROOT = process.cwd();
|
|
35
|
+
const GUARD_NAME = "test-title";
|
|
36
|
+
const IGNORE_TAG = `kumiko-lint-ignore ${GUARD_NAME}`;
|
|
37
|
+
|
|
38
|
+
// Playwright specs sit outside every manifest's testGlobs; so do bin and sample-app tests.
|
|
39
|
+
const SCAN: ScanSpec = {
|
|
40
|
+
scope: "tests",
|
|
41
|
+
kinds: ALL_REPO_KINDS,
|
|
42
|
+
extensions: ["ts", "tsx"],
|
|
43
|
+
extraGlobs: [
|
|
44
|
+
"packages/*/src/**/__tests__/**/*.{ts,tsx}",
|
|
45
|
+
"packages/*/bin/**/__tests__/**/*.{ts,tsx}",
|
|
46
|
+
"samples/apps/**/*.{test,spec}.{ts,tsx}",
|
|
47
|
+
"samples/apps/**/__tests__/**/*.{ts,tsx}",
|
|
48
|
+
"e2e/**/*.{ts,tsx}",
|
|
49
|
+
"packages/*/e2e/**/*.{ts,tsx}",
|
|
50
|
+
"packages/*/src/e2e/**/*.{ts,tsx}",
|
|
51
|
+
"samples/e2e/**/*.{ts,tsx}",
|
|
52
|
+
"samples/recipes/*/e2e/**/*.{ts,tsx}",
|
|
53
|
+
"samples/apps/*/e2e/**/*.{ts,tsx}",
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
|
|
58
|
+
const PLAYWRIGHT_DESCRIBE_CALLEE = /^test\.(describe|fixme|fail)(\.\w+)*$/;
|
|
59
|
+
|
|
60
|
+
type TitleFinding = { readonly line: number; readonly title: string; readonly reason: string };
|
|
61
|
+
|
|
62
|
+
export function hasIssueReference(title: string): boolean {
|
|
63
|
+
return (title.match(REFERENCE_RE)?.length ?? 0) > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function titleReason(title: string): string | undefined {
|
|
67
|
+
if (isGermanTestTitle(title)) return "German";
|
|
68
|
+
if (hasIssueReference(title)) return "issue reference";
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isTitleLiteral(arg: Node): boolean {
|
|
73
|
+
return (
|
|
74
|
+
Node.isStringLiteral(arg) ||
|
|
75
|
+
Node.isNoSubstitutionTemplateLiteral(arg) ||
|
|
76
|
+
Node.isTemplateExpression(arg)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function titleLiteralText(arg: Node | undefined): string | undefined {
|
|
81
|
+
if (arg === undefined || !isTitleLiteral(arg)) return undefined;
|
|
82
|
+
return arg.getText().slice(1, -1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isTestDeclaration(call: CallExpression, aliases: ReadonlyMap<string, string>): boolean {
|
|
86
|
+
const base = canonicalCalleeText(declarationBaseCallee(call.getExpression()).getText(), aliases);
|
|
87
|
+
return TEST_DECLARATION_CALLEE.test(base) || PLAYWRIGHT_DESCRIBE_CALLEE.test(base);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function findBadTestTitles(sf: SourceFile): TitleFinding[] {
|
|
91
|
+
const aliases = testFrameworkAliases(sf);
|
|
92
|
+
const findings: TitleFinding[] = [];
|
|
93
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
94
|
+
if (!isTestDeclaration(call, aliases)) continue;
|
|
95
|
+
const arg = call.getArguments()[0];
|
|
96
|
+
const title = titleLiteralText(arg);
|
|
97
|
+
if (arg === undefined || title === undefined) continue;
|
|
98
|
+
const reason = titleReason(title);
|
|
99
|
+
if (reason === undefined) continue;
|
|
100
|
+
if (hasIgnoreTag(arg, IGNORE_TAG) || hasIgnoreTag(call, IGNORE_TAG)) continue;
|
|
101
|
+
findings.push({ line: arg.getStartLineNumber(), title, reason });
|
|
102
|
+
}
|
|
103
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function testTitleCounts(
|
|
107
|
+
files: readonly SourceFile[],
|
|
108
|
+
roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
|
|
109
|
+
): { counts: Record<string, number>; firstLine: Record<string, number> } {
|
|
110
|
+
const counts: Record<string, number> = {};
|
|
111
|
+
const firstLine: Record<string, number> = {};
|
|
112
|
+
for (const sf of files) {
|
|
113
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
114
|
+
const findings = findBadTestTitles(sf);
|
|
115
|
+
const first = findings[0];
|
|
116
|
+
if (first === undefined) continue;
|
|
117
|
+
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
118
|
+
if (!isLocalFinding({ file })) continue;
|
|
119
|
+
counts[file] = findings.length;
|
|
120
|
+
firstLine[file] = first.line;
|
|
121
|
+
}
|
|
122
|
+
return { counts, firstLine };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const testTitleBaseline = baselineRatchet({
|
|
126
|
+
file: path.join(ROOT, ".kumiko-test-title-baseline.json"),
|
|
127
|
+
formatVersion: 1,
|
|
128
|
+
unit: "German or issue-referencing test title(s)",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const REMEDIATION =
|
|
132
|
+
"Write the title in English and state the behaviour instead of the issue number; the number belongs in the commit message.";
|
|
133
|
+
|
|
134
|
+
export const guard: AstGuard = {
|
|
135
|
+
name: GUARD_NAME,
|
|
136
|
+
scan: SCAN,
|
|
137
|
+
hint: `after fixing hits: \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\`; deliberate exception (e.g. a German fixture string): // ${IGNORE_TAG} <reason>`,
|
|
138
|
+
run: (files, roots = resolveRepoRoots()) => {
|
|
139
|
+
const { counts, firstLine } = testTitleCounts(files, roots);
|
|
140
|
+
return {
|
|
141
|
+
violations: testTitleBaseline.check(counts, REMEDIATION, {
|
|
142
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\` once.`,
|
|
143
|
+
resolveLine: (file) => firstLine[file] ?? 1,
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
},
|
|
147
|
+
writeBaseline: (files, roots = resolveRepoRoots()) => {
|
|
148
|
+
testTitleBaseline.write(testTitleCounts(files, roots).counts);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (import.meta.main) {
|
|
153
|
+
if (process.argv.slice(2).includes("--write-baseline")) {
|
|
154
|
+
const project = buildSharedProject([guard]);
|
|
155
|
+
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
156
|
+
process.exit(0);
|
|
157
|
+
}
|
|
158
|
+
runStandalone(guard);
|
|
159
|
+
}
|
package/src/run-guards.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { guard as htmlEscape } from "./guard-html-escape";
|
|
|
37
37
|
import { guard as i18nKeys } from "./guard-i18n-keys";
|
|
38
38
|
import { guard as i18nLocaleMount } from "./guard-i18n-locale-mount";
|
|
39
39
|
import { guard as i18nLocaleTerminology } from "./guard-i18n-locale-terminology";
|
|
40
|
+
import { guard as ignoreTagReason } from "./guard-ignore-tag-reason";
|
|
40
41
|
import { guard as importExtensions } from "./guard-import-extensions";
|
|
41
42
|
import { guard as internalSubpathImports } from "./guard-internal-subpath-imports";
|
|
42
43
|
import { guard as libTestCoverage } from "./guard-lib-test-coverage";
|
|
@@ -53,6 +54,7 @@ import { guard as testEsFixtures } from "./guard-test-es-fixtures";
|
|
|
53
54
|
import { guard as testEsRowWrites } from "./guard-test-es-row-writes";
|
|
54
55
|
import { guard as testTemplateDrift } from "./guard-test-template-drift";
|
|
55
56
|
import { guard as testTimeouts } from "./guard-test-timeouts";
|
|
57
|
+
import { guard as testTitle } from "./guard-test-title";
|
|
56
58
|
import { guard as unsafeJsonParse } from "./guard-unsafe-json-parse";
|
|
57
59
|
import { guard as writeHandlerQns } from "./guard-write-handler-qns";
|
|
58
60
|
|
|
@@ -92,6 +94,8 @@ export const GUARDS = [
|
|
|
92
94
|
testTemplateDrift,
|
|
93
95
|
fileSize,
|
|
94
96
|
commentIssueRefOnly,
|
|
97
|
+
testTitle,
|
|
98
|
+
ignoreTagReason,
|
|
95
99
|
];
|
|
96
100
|
|
|
97
101
|
export const GUARD_FLAGS = [
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { check as secretLiterals } from "./check-secret-literals";
|
|
|
9
9
|
import { check as singleRuntimeInstance } from "./check-single-runtime-instance";
|
|
10
10
|
import { check as subpathExports } from "./check-subpath-exports";
|
|
11
11
|
import { check as agentDocsDrift } from "./guard-agent-docs-drift";
|
|
12
|
+
import { check as appDockerfile } from "./guard-app-dockerfile";
|
|
13
|
+
import { check as docStatus } from "./guard-doc-status";
|
|
12
14
|
import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
|
|
13
15
|
import { check as guardsDocDrift } from "./guard-guards-doc-drift";
|
|
14
16
|
import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
|
|
@@ -39,6 +41,8 @@ export const REPO_CHECKS = [
|
|
|
39
41
|
guardsDocDrift,
|
|
40
42
|
unsafeArrayParam,
|
|
41
43
|
schemaCliFeatures,
|
|
44
|
+
appDockerfile,
|
|
45
|
+
docStatus,
|
|
42
46
|
];
|
|
43
47
|
|
|
44
48
|
export const REPO_CHECK_FLAGS: readonly string[] = ["--write-baseline"];
|