@cosmicdrift/kumiko-guards 0.345.0 → 0.347.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.345.0",
3
+ "version": "0.347.0",
4
4
  "description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,7 +28,7 @@
28
28
  "kumiko-pre-push": "./src/pre-push.sh"
29
29
  },
30
30
  "dependencies": {
31
- "@cosmicdrift/kumiko-repo-manifest": "0.345.0",
31
+ "@cosmicdrift/kumiko-repo-manifest": "0.347.0",
32
32
  "ts-morph": "^28.0.0"
33
33
  },
34
34
  "publishConfig": {
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.347.0",
4
+ "type": "improvement",
5
+ "title": "New kumiko-guards comment-lang subcommand replaces the kumiko-guard-comment-lang bin",
6
+ "detail": "`kumiko-guards comment-lang` runs the comment-language ratchet\nThe subcommand takes `--touched --base=<ref>`, `--list`, `--write-baseline` and `--no-baseline`. The pre-push hook calls it instead of the separate `kumiko-guard-comment-lang` bin."
7
+ },
2
8
  {
3
9
  "version": "0.341.0",
4
10
  "type": "breaking",
package/src/cli.ts CHANGED
@@ -5,17 +5,19 @@
5
5
  // flags, which each runner's own *Cli function validates and applies so this
6
6
  // bin and the direct `bun run-*.ts` invocation can never drift apart.
7
7
  import { buildGuardKitInventory, cliFlagsError } from "./_lib/guard-kit";
8
+ import { COMMENT_LANG_FLAGS, runCommentLangCli } from "./guard-comment-lang";
8
9
  import { GUARD_FLAGS, GUARDS, runGuardsCli } from "./run-guards";
9
10
  import { REPO_CHECK_FLAGS, REPO_CHECKS, runRepoChecksCli } from "./run-repo-checks";
10
11
  import { runUiGuardsCli, UI_GUARD_FLAGS, UI_GUARDS } from "./run-ui-guards";
11
12
 
12
- const SUBCOMMANDS = ["guards", "ui", "checks", "list"] as const;
13
+ const SUBCOMMANDS = ["guards", "ui", "checks", "comment-lang", "list"] as const;
13
14
  type Subcommand = (typeof SUBCOMMANDS)[number];
14
15
 
15
16
  const SUBCOMMAND_FLAGS: Record<Subcommand, readonly string[]> = {
16
17
  guards: GUARD_FLAGS,
17
18
  ui: UI_GUARD_FLAGS,
18
19
  checks: REPO_CHECK_FLAGS,
20
+ "comment-lang": [...COMMENT_LANG_FLAGS, "--base=<ref>"],
19
21
  list: [],
20
22
  };
21
23
 
@@ -24,7 +26,7 @@ function isSubcommand(value: string): value is Subcommand {
24
26
  }
25
27
 
26
28
  function printHelp(): void {
27
- console.log("Usage: kumiko-guards [guards|ui|checks|list] [flags]");
29
+ console.log("Usage: kumiko-guards [guards|ui|checks|comment-lang|list] [flags]");
28
30
  console.log();
29
31
  for (const sub of SUBCOMMANDS) {
30
32
  const flags = SUBCOMMAND_FLAGS[sub];
@@ -71,6 +73,11 @@ async function main(): Promise<void> {
71
73
  return;
72
74
  }
73
75
 
76
+ // Not part of the no-subcommand run: it needs a baseline or a git base ref.
77
+ if (subcommand === "comment-lang") {
78
+ process.exit(runCommentLangCli(flags));
79
+ }
80
+
74
81
  let failed = 0;
75
82
  if (subcommand === undefined || subcommand === "guards") {
76
83
  failed += runGuardsCli(subcommand === "guards" ? flags : []);
@@ -0,0 +1,392 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { ts } from "ts-morph";
4
+ import { gitEnv } from "./_lib/git-env";
5
+ import { cliFlagsError, isLocalFinding } from "./_lib/guard-kit";
6
+ import { resolveRepoRoots } from "./_lib/roots";
7
+ import { type ScanSpec, scanFiles } from "./_lib/scan-scope";
8
+
9
+ // Comment-language ratchet: no new German comments in product code. No NLP —
10
+ // German markers are common function words or umlaut characters in the comment text.
11
+ // Baseline mode compares per-file counts against a frozen file (more than the
12
+ // baseline fails, fewer is allowed but does not rewrite it); `--touched` fails
13
+ // on any German comment that overlaps a line added since `--base`.
14
+
15
+ export const COMMENT_LANG_FLAGS: readonly string[] = [
16
+ "--touched",
17
+ "--list",
18
+ "--write-baseline",
19
+ "--no-baseline",
20
+ ];
21
+
22
+ const BASE_FLAG_PREFIX = "--base=";
23
+ const BASELINE_FILE = ".kumiko-comment-lang-baseline.json";
24
+ const BASELINE_FORMAT_VERSION = 1;
25
+ const LIST_LIMIT = 200;
26
+ const IGNORE_TAG = "kumiko-lint-ignore comment-lang";
27
+
28
+ const SCAN: ScanSpec = {
29
+ scope: "source",
30
+ extensions: ["ts", "tsx"],
31
+ frameworkWithin: ["packages/*/src/**"],
32
+ };
33
+
34
+ const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
35
+
36
+ const DE_MARKER_RE =
37
+ /\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/i;
38
+ const UMLAUT_RE = /[äöüÄÖÜß]/;
39
+
40
+ export function isGermanComment(text: string): boolean {
41
+ return DE_MARKER_RE.test(text) || UMLAUT_RE.test(text);
42
+ }
43
+
44
+ export type Site = {
45
+ readonly file: string;
46
+ readonly line: number;
47
+ /** Last line of a multi-line comment: touched mode must catch a German sentence added mid-comment, not only on the opening line. */
48
+ readonly endLine: number;
49
+ readonly snippet: string;
50
+ };
51
+
52
+ // Template substitutions can nest braces (object literals, arrow bodies, other
53
+ // templates). The raw scanner tokenizes a `}` as a plain CloseBraceToken and
54
+ // never resumes template mode, so reScanTemplateToken() must run once brace
55
+ // depth returns to the level the substitution opened at.
56
+ export function scanGermanComments(fullText: string, file: string): Site[] {
57
+ const sites: Site[] = [];
58
+ const scanner = ts.createScanner(
59
+ ts.ScriptTarget.Latest,
60
+ /* skipTrivia */ false,
61
+ ts.LanguageVariant.Standard,
62
+ fullText,
63
+ );
64
+
65
+ const templateBraceDepths: number[] = [];
66
+ let braceDepth = 0;
67
+
68
+ let kind = scanner.scan();
69
+ while (kind !== ts.SyntaxKind.EndOfFileToken) {
70
+ switch (kind) {
71
+ case ts.SyntaxKind.SingleLineCommentTrivia:
72
+ case ts.SyntaxKind.MultiLineCommentTrivia: {
73
+ const text = scanner.getTokenText();
74
+ if (!text.includes(IGNORE_TAG) && isGermanComment(text)) {
75
+ const line = fullText.slice(0, scanner.getTokenStart()).split("\n").length;
76
+ sites.push({
77
+ file,
78
+ line,
79
+ endLine: line + (text.match(/\n/g)?.length ?? 0),
80
+ snippet: text.length > 80 ? `${text.slice(0, 77)}...` : text,
81
+ });
82
+ }
83
+ break;
84
+ }
85
+ case ts.SyntaxKind.SlashToken:
86
+ case ts.SyntaxKind.SlashEqualsToken: {
87
+ // The raw scanner always yields SlashToken for `/`; without the rescan a
88
+ // regex body like /\{/ would shift braceDepth and end a template early.
89
+ const reScanned = scanner.reScanSlashToken();
90
+ if (reScanned === ts.SyntaxKind.RegularExpressionLiteral) kind = reScanned;
91
+ break;
92
+ }
93
+ case ts.SyntaxKind.TemplateHead:
94
+ case ts.SyntaxKind.TemplateMiddle:
95
+ templateBraceDepths.push(braceDepth);
96
+ break;
97
+ case ts.SyntaxKind.OpenBraceToken:
98
+ braceDepth++;
99
+ break;
100
+ case ts.SyntaxKind.CloseBraceToken:
101
+ if (
102
+ templateBraceDepths.length > 0 &&
103
+ braceDepth === templateBraceDepths[templateBraceDepths.length - 1]
104
+ ) {
105
+ templateBraceDepths.pop();
106
+ kind = scanner.reScanTemplateToken(false);
107
+ continue;
108
+ }
109
+ if (braceDepth > 0) braceDepth--;
110
+ break;
111
+ default:
112
+ break;
113
+ }
114
+ kind = scanner.scan();
115
+ }
116
+ return sites;
117
+ }
118
+
119
+ // `--unified=0` hunks carry only changed lines, so `+newStart,newLines` alone
120
+ // is the added range. `newLines` omitted means 1; `,0` is a pure deletion.
121
+ export function parseAddedLineRanges(diffText: string): Set<number> {
122
+ const added = new Set<number>();
123
+ const hunkHeaderRe = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
124
+ for (const line of diffText.split("\n")) {
125
+ const match = hunkHeaderRe.exec(line);
126
+ if (!match) continue;
127
+ const newStart = Number(match[1]);
128
+ const newLines = match[2] !== undefined ? Number(match[2]) : 1;
129
+ for (let i = 0; i < newLines; i++) added.add(newStart + i);
130
+ }
131
+ return added;
132
+ }
133
+
134
+ export function sitesTouchedByAddedLines(
135
+ sites: readonly Site[],
136
+ addedLines: ReadonlySet<number>,
137
+ ): Site[] {
138
+ return sites.filter((site) => {
139
+ for (let line = site.line; line <= site.endLine; line++) {
140
+ if (addedLines.has(line)) return true;
141
+ }
142
+ return false;
143
+ });
144
+ }
145
+
146
+ type GitResult = { readonly exitCode: number; readonly stdout: string; readonly stderr: string };
147
+
148
+ function runGit(args: readonly string[], cwd: string): GitResult {
149
+ const proc = Bun.spawnSync(["git", ...args], {
150
+ cwd,
151
+ env: gitEnv(),
152
+ stdout: "pipe",
153
+ stderr: "pipe",
154
+ });
155
+ return {
156
+ exitCode: proc.exitCode ?? 1,
157
+ stdout: proc.stdout.toString(),
158
+ stderr: proc.stderr.toString(),
159
+ };
160
+ }
161
+
162
+ class CommentLangFailure extends Error {}
163
+
164
+ function gitOrFail(args: readonly string[], cwd: string, message: string): string {
165
+ const result = runGit(args, cwd);
166
+ if (result.exitCode !== 0)
167
+ throw new CommentLangFailure(`${message}\n ${result.stderr.trim()}`);
168
+ return result.stdout;
169
+ }
170
+
171
+ const REWRITE_HINT = `Translate new German comments to English (why, not what), or if deliberate:\n // ${IGNORE_TAG} <reason>`;
172
+
173
+ function runTouchedMode(
174
+ all: readonly Site[],
175
+ argv: readonly string[],
176
+ root: string,
177
+ repoLocalScannedFiles: number,
178
+ ): number {
179
+ const baseArg = argv.find((arg) => arg.startsWith(BASE_FLAG_PREFIX));
180
+ const baseRef = baseArg ? baseArg.slice(BASE_FLAG_PREFIX.length) : "origin/main";
181
+ if (baseRef.startsWith("-")) {
182
+ throw new CommentLangFailure(
183
+ `--base must not start with "-" (${baseRef}): it would be read as a git option, not a ref.`,
184
+ );
185
+ }
186
+
187
+ // No repo-local file scanned at all must not read as clean (vacuous pass).
188
+ if (repoLocalScannedFiles === 0) {
189
+ console.log(" Touched mode: 0 repo-local files scanned — not applicable, no failure.");
190
+ return 0;
191
+ }
192
+
193
+ const gitToplevel = gitOrFail(
194
+ ["rev-parse", "--show-toplevel"],
195
+ root,
196
+ "Touched mode: no git repository found.",
197
+ ).trim();
198
+ const mergeBase = gitOrFail(
199
+ ["merge-base", baseRef, "HEAD"],
200
+ gitToplevel,
201
+ `Touched mode: git merge-base ${baseRef} HEAD failed — is ${baseRef} known locally? On shallow checkouts fetch the full history or the ref.`,
202
+ ).trim();
203
+ // -z: NUL-terminated and unquoted, so non-ASCII paths still match the lookup below.
204
+ const touchedFiles = gitOrFail(
205
+ ["diff", "--name-only", "-z", "--diff-filter=ACMR", mergeBase],
206
+ gitToplevel,
207
+ "Touched mode: git diff --name-only failed.",
208
+ )
209
+ .split("\0")
210
+ .filter(Boolean);
211
+
212
+ // Sites are keyed relative to root, git output relative to the toplevel;
213
+ // a silent mismatch would read as "no violations".
214
+ const sitesByGitFile = new Map<string, Site[]>();
215
+ for (const site of all.filter(isLocalFinding)) {
216
+ const gitRelPath = path.relative(gitToplevel, path.resolve(root, site.file));
217
+ sitesByGitFile.set(gitRelPath, [...(sitesByGitFile.get(gitRelPath) ?? []), site]);
218
+ }
219
+
220
+ const violations: Array<{ file: string; sites: Site[] }> = [];
221
+ for (const file of touchedFiles) {
222
+ const candidates = sitesByGitFile.get(file);
223
+ if (!candidates || candidates.length === 0) continue;
224
+ // --literal-pathspecs: filenames with glob magic must match themselves.
225
+ const addedLines = parseAddedLineRanges(
226
+ gitOrFail(
227
+ ["--literal-pathspecs", "diff", "--unified=0", mergeBase, "--", file],
228
+ gitToplevel,
229
+ `Touched mode: git diff --unified=0 failed for ${file}.`,
230
+ ),
231
+ );
232
+ const touchedSites = sitesTouchedByAddedLines(candidates, addedLines);
233
+ if (touchedSites.length > 0) violations.push({ file, sites: touchedSites });
234
+ }
235
+
236
+ if (violations.length === 0) {
237
+ console.log(
238
+ ` ✓ Touched mode: no new German comments in ${touchedFiles.length} changed file(s) since ${baseRef}.`,
239
+ );
240
+ return 0;
241
+ }
242
+ console.log(
243
+ `\n TOUCHED-MODE REGRESSION: ${violations.length} changed file(s) with new German comments (since ${baseRef}):`,
244
+ );
245
+ for (const violation of violations) {
246
+ console.log(` ${violation.file}`);
247
+ for (const site of violation.sites) console.log(` :${site.line} ${site.snippet}`);
248
+ }
249
+ console.log(`\n ${REWRITE_HINT}`);
250
+ return 1;
251
+ }
252
+
253
+ type Baseline = {
254
+ readonly format: number;
255
+ readonly generated: string;
256
+ readonly total: number;
257
+ readonly perFile: Record<string, number>;
258
+ };
259
+
260
+ function sumCounts(counts: Readonly<Record<string, number>>): number {
261
+ return Object.values(counts).reduce((total, count) => total + count, 0);
262
+ }
263
+
264
+ function runBaselineMode(all: readonly Site[], argv: readonly string[], root: string): number {
265
+ const baselinePath = path.join(root, BASELINE_FILE);
266
+ const countByFile: Record<string, number> = {};
267
+ for (const site of all.filter(isLocalFinding)) {
268
+ countByFile[site.file] = (countByFile[site.file] ?? 0) + 1;
269
+ }
270
+ const currentTotal = sumCounts(countByFile);
271
+
272
+ if (argv.includes("--write-baseline")) {
273
+ const payload: Baseline = {
274
+ format: BASELINE_FORMAT_VERSION,
275
+ generated: new Date().toISOString().slice(0, 10),
276
+ total: currentTotal,
277
+ perFile: Object.fromEntries(
278
+ Object.entries(countByFile).sort(([a], [b]) => a.localeCompare(b)),
279
+ ),
280
+ };
281
+ writeFileSync(baselinePath, `${JSON.stringify(payload, null, 2)}\n`);
282
+ console.log(` Baseline written: ${baselinePath} (total ${currentTotal})`);
283
+ return 0;
284
+ }
285
+ if (argv.includes("--no-baseline")) {
286
+ console.log(" Baseline comparison skipped (--no-baseline).");
287
+ return 0;
288
+ }
289
+ if (!existsSync(baselinePath)) {
290
+ console.log(" No baseline found. Freeze one first with `--write-baseline`.");
291
+ return 1;
292
+ }
293
+
294
+ const rawBaseline: Partial<Baseline> = JSON.parse(readFileSync(baselinePath, "utf-8"));
295
+ if (rawBaseline.format !== BASELINE_FORMAT_VERSION || rawBaseline.perFile === undefined) {
296
+ console.log(
297
+ ` Baseline format drift: expected format=${BASELINE_FORMAT_VERSION}, got format=${rawBaseline.format ?? "<missing>"}.`,
298
+ );
299
+ console.log(" Rewrite it once with: kumiko-guards comment-lang --write-baseline");
300
+ return 1;
301
+ }
302
+ const baselinePerFile = rawBaseline.perFile;
303
+
304
+ const regressions: Array<{ file: string; baseline: number; current: number }> = [];
305
+ let reduced = 0;
306
+ for (const file of new Set([...Object.keys(countByFile), ...Object.keys(baselinePerFile)])) {
307
+ const expected = baselinePerFile[file] ?? 0;
308
+ const current = countByFile[file] ?? 0;
309
+ if (current > expected) regressions.push({ file, baseline: expected, current });
310
+ else if (current < expected) reduced += expected - current;
311
+ }
312
+
313
+ if (regressions.length > 0) {
314
+ console.log(
315
+ `\n REGRESSION: ${regressions.length} file(s) have more German comments than the baseline:`,
316
+ );
317
+ for (const regression of regressions) {
318
+ console.log(
319
+ ` ${regression.file} baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline})`,
320
+ );
321
+ for (const site of all.filter((candidate) => candidate.file === regression.file)) {
322
+ console.log(` :${site.line} ${site.snippet}`);
323
+ }
324
+ }
325
+ console.log(`\n ${REWRITE_HINT}`);
326
+ return 1;
327
+ }
328
+
329
+ console.log(` ✓ Baseline (${rawBaseline.total}) — current ${currentTotal}`);
330
+ if (reduced > 0) {
331
+ console.log(
332
+ ` ✓ ${reduced} German comment(s) removed since the baseline. Consider \`--write-baseline\`.`,
333
+ );
334
+ }
335
+ return 0;
336
+ }
337
+
338
+ // `cwd` resolves the repo to scan and the baseline path; KUMIKO_PUSH_REPO_ROOT
339
+ // overrides it because the pre-push hook cds into the parent workspace first.
340
+ export function runCommentLangCli(
341
+ argv: readonly string[],
342
+ cwd: string = process.cwd(),
343
+ env: Readonly<Record<string, string | undefined>> = process.env,
344
+ ): number {
345
+ const knownFlags = argv.filter((arg) => !arg.startsWith(BASE_FLAG_PREFIX));
346
+ const flagsError = cliFlagsError("comment-lang", knownFlags, COMMENT_LANG_FLAGS);
347
+ if (flagsError !== undefined) {
348
+ console.error(flagsError);
349
+ return 1;
350
+ }
351
+
352
+ const pushRoot = env["KUMIKO_PUSH_REPO_ROOT"];
353
+ const root = pushRoot && existsSync(pushRoot) ? pushRoot : cwd;
354
+
355
+ try {
356
+ const all: Site[] = [];
357
+ let scanned = 0;
358
+ let repoLocalScanned = 0;
359
+ for (const absPath of scanFiles(SCAN, resolveRepoRoots(root))) {
360
+ if (EXCLUDE.test(absPath)) continue;
361
+ scanned++;
362
+ const file = path.relative(root, absPath);
363
+ if (isLocalFinding({ file })) repoLocalScanned++;
364
+ all.push(...scanGermanComments(readFileSync(absPath, "utf-8"), file));
365
+ }
366
+
367
+ // Zero scanned files must not read as a clean pass (misresolved root, empty scope).
368
+ if (scanned === 0) {
369
+ throw new CommentLangFailure("Scanned 0 files — repo root or scan scope did not resolve.");
370
+ }
371
+
372
+ console.log(
373
+ `Comment-Language Audit: ${scanned} files checked, ${all.length} German comment(s) found.`,
374
+ );
375
+
376
+ if (argv.includes("--touched")) return runTouchedMode(all, argv, root, repoLocalScanned);
377
+ if (argv.includes("--list")) {
378
+ for (const site of all.slice(0, LIST_LIMIT)) {
379
+ console.log(` ${site.file}:${site.line} ${site.snippet}`);
380
+ }
381
+ if (all.length > LIST_LIMIT) console.log(` ... (${all.length - LIST_LIMIT} more)`);
382
+ return 0;
383
+ }
384
+ return runBaselineMode(all, argv, root);
385
+ } catch (error) {
386
+ if (error instanceof CommentLangFailure) {
387
+ console.log(` ${error.message}`);
388
+ return 1;
389
+ }
390
+ throw error;
391
+ }
392
+ }
package/src/pre-push.sh CHANGED
@@ -7,7 +7,7 @@
7
7
  # 2. Else if running inside a worktree under the parent workspace (no
8
8
  # check-wt.sh), run this worktree's own package.json scripts
9
9
  # (typecheck, lint, test, test:dom) plus the consumer-CI guards
10
- # (kumiko-guards guards/checks, kumiko-guard-comment-lang) directly —
10
+ # (kumiko-guards guards/checks/comment-lang) directly —
11
11
  # the parent's `bun check` would otherwise check the main checkout
12
12
  # instead of the worktree.
13
13
  # 3. Else if running inside the cosmicdriftgamestudio Parent-Workspace,
@@ -175,7 +175,7 @@ if [ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ]
175
175
  done
176
176
  run_guard_bin guards kumiko-guards guards
177
177
  run_guard_bin checks kumiko-guards checks
178
- run_guard_bin comment-lang kumiko-guard-comment-lang --touched --base=origin/main
178
+ run_guard_bin comment-lang kumiko-guards comment-lang --touched --base=origin/main
179
179
  if [ -n "$FAILED" ]; then
180
180
  echo "[pre-push] worktree check failed:$FAILED" >&2
181
181
  exit 1