@cosmicdrift/kumiko-guards 0.1.1 → 0.3.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.
Files changed (52) hide show
  1. package/package.json +4 -1
  2. package/src/_lib/guard-kit.ts +74 -19
  3. package/src/_lib/qn.ts +21 -0
  4. package/src/_lib/security-baseline-cli.ts +3 -3
  5. package/src/_lib/security-baseline.ts +8 -8
  6. package/src/changes.json +32 -0
  7. package/src/check-as-casts.ts +648 -0
  8. package/src/check-complexity.ts +292 -0
  9. package/src/check-predicates.ts +218 -0
  10. package/src/check-secret-literals.ts +126 -0
  11. package/src/cli.ts +59 -0
  12. package/src/guard-admin-api.ts +1 -1
  13. package/src/guard-app-feature-structure.ts +114 -0
  14. package/src/guard-broker-subscribe.ts +99 -0
  15. package/src/guard-error-reasons.ts +185 -0
  16. package/src/guard-escape-hatch-declared.ts +26 -19
  17. package/src/guard-fake-tests.ts +1 -1
  18. package/src/guard-feature-integration-tests.ts +184 -0
  19. package/src/guard-html-escape.ts +1 -1
  20. package/src/guard-i18n-keys.ts +440 -0
  21. package/src/guard-i18n-locale-mount.ts +317 -0
  22. package/src/guard-i18n-locale-terminology.ts +117 -0
  23. package/src/guard-i18n-ui-strings.ts +248 -0
  24. package/src/guard-lib-test-coverage.ts +156 -0
  25. package/src/guard-loadall-events.ts +133 -0
  26. package/src/guard-no-custom-primitives.ts +9 -10
  27. package/src/guard-no-date-api.ts +1 -1
  28. package/src/guard-no-direct-fs.ts +1 -1
  29. package/src/guard-no-inline-styles.ts +4 -4
  30. package/src/guard-no-logic-in-views.ts +3 -3
  31. package/src/guard-no-raw-hooks.ts +4 -5
  32. package/src/guard-open-to-all-reason.ts +1 -1
  33. package/src/guard-pii-annotations.ts +267 -0
  34. package/src/guard-pre-es-patterns.ts +1 -1
  35. package/src/guard-primitives-discipline.ts +3 -3
  36. package/src/guard-raw-classname.ts +3 -3
  37. package/src/guard-raw-interactive-elements.ts +3 -3
  38. package/src/guard-raw-sql.ts +2 -2
  39. package/src/guard-renderer-boundaries.ts +1 -1
  40. package/src/guard-restricted-symbols.ts +1 -1
  41. package/src/guard-screen-conventions.ts +161 -0
  42. package/src/guard-silent-skip.ts +1 -1
  43. package/src/guard-table-ddl.ts +159 -0
  44. package/src/guard-tailwind-scan-surface.ts +12 -12
  45. package/src/guard-test-stack-drift.ts +147 -0
  46. package/src/guard-text-field-stance.ts +222 -0
  47. package/src/guard-thin-wrappers.ts +6 -1
  48. package/src/guard-unsafe-json-parse.ts +1 -1
  49. package/src/guard-write-handler-qns.ts +242 -0
  50. package/src/run-guards.ts +36 -3
  51. package/src/run-repo-checks.ts +10 -1
  52. package/src/run-ui-guards.ts +11 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-guards",
3
- "version": "0.1.1",
3
+ "version": "0.3.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>",
@@ -23,6 +23,9 @@
23
23
  "default": "./src/index.ts"
24
24
  }
25
25
  },
26
+ "bin": {
27
+ "kumiko-guards": "./src/cli.ts"
28
+ },
26
29
  "dependencies": {
27
30
  "@cosmicdrift/kumiko-repo-manifest": "0.1.0",
28
31
  "ts-morph": "^28.0.0"
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
16
- import { relative as pathRelative } from "node:path";
16
+ import { relative as pathRelative, resolve } from "node:path";
17
17
  import { Project, type SourceFile } from "ts-morph";
18
18
  import { compareToBaseline, findRepoRootFor } from "./baseline-compare";
19
19
  import {
@@ -126,6 +126,13 @@ export function isAllowlisted(relPath: string, allowlist: readonly RegExp[]): bo
126
126
  return allowlist.some((re) => re.test(relPath));
127
127
  }
128
128
 
129
+ // allRepos guards render sibling hits as "../<repo>/..." relative to ROOT —
130
+ // freezing those into a repo-local baseline would fail a sibling's own
131
+ // refactor locally while CI (no sibling checked out there) never sees it.
132
+ export function isLocalFinding<T extends { readonly file: string }>(item: T): boolean {
133
+ return !item.file.startsWith("../");
134
+ }
135
+
129
136
  type BaselinePayload = {
130
137
  readonly format: number;
131
138
  readonly generated: string;
@@ -161,7 +168,7 @@ export function baselineRatchet(args: {
161
168
  perFile,
162
169
  };
163
170
  writeFileSync(args.file, `${JSON.stringify(payload, null, 2)}\n`);
164
- console.log(` Baseline geschrieben: ${args.file} (total ${total})`);
171
+ console.log(` Baseline written: ${args.file} (total ${total})`);
165
172
  };
166
173
  const check = (
167
174
  current: Readonly<Record<string, number>>,
@@ -173,7 +180,7 @@ export function baselineRatchet(args: {
173
180
  ): GuardViolation[] => {
174
181
  if (!existsSync(args.file)) {
175
182
  console.log(
176
- ` Keine Baseline gefunden (${args.file}). Erst mit \`--write-baseline\` einfrieren bis dahin Warnung, kein Fail.`,
183
+ ` No baseline found (${args.file}). Freeze it first with \`--write-baseline\` — warning until then, no fail.`,
177
184
  );
178
185
  return [];
179
186
  }
@@ -185,7 +192,7 @@ export function baselineRatchet(args: {
185
192
  {
186
193
  file: args.file,
187
194
  line: 1,
188
- message: `Baseline-Datei nicht lesbar (kaputtes JSON, Merge-Marker, abgebrochener Write): ${e instanceof Error ? e.message : String(e)}. ${opts?.formatDriftRemediation ?? remediation}`,
195
+ message: `Baseline file unreadable (broken JSON, merge marker, aborted write): ${e instanceof Error ? e.message : String(e)}. ${opts?.formatDriftRemediation ?? remediation}`,
189
196
  },
190
197
  ];
191
198
  }
@@ -200,8 +207,8 @@ export function baselineRatchet(args: {
200
207
  line: 1,
201
208
  message:
202
209
  raw.format !== args.formatVersion
203
- ? `Baseline-Format-Drift: erwartet format=${args.formatVersion}, gelesen format=${raw.format ?? "<missing>"}. ${opts?.formatDriftRemediation ?? remediation}`
204
- : `Baseline-Datei hat kein gültiges "perFile"-Objekt. ${opts?.formatDriftRemediation ?? remediation}`,
210
+ ? `Baseline format drift: expected format=${args.formatVersion}, read format=${raw.format ?? "<missing>"}. ${opts?.formatDriftRemediation ?? remediation}`
211
+ : `Baseline file has no valid "perFile" object. ${opts?.formatDriftRemediation ?? remediation}`,
205
212
  },
206
213
  ];
207
214
  }
@@ -209,14 +216,14 @@ export function baselineRatchet(args: {
209
216
  const { regressions, reduced } = compareToBaseline(current, baseline.perFile);
210
217
  if (regressions.length === 0) {
211
218
  const total = Object.values(current).reduce((sum, count) => sum + count, 0);
212
- console.log(` ✓ Baseline (${baseline.total}) — aktuell ${total}`);
213
- if (reduced > 0) console.log(` ✓ ${reduced} ${args.unit} reduziert seit Baseline.`);
219
+ console.log(` ✓ Baseline (${baseline.total}) — current ${total}`);
220
+ if (reduced > 0) console.log(` ✓ ${reduced} ${args.unit} reduced since baseline.`);
214
221
  return [];
215
222
  }
216
223
  return regressions.map((regression) => ({
217
224
  file: regression.file,
218
225
  line: opts?.resolveLine?.(regression.file) ?? 1,
219
- message: `${args.unit} über Baseline: baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline}). ${remediation}`,
226
+ message: `${args.unit} over baseline: baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline}). ${remediation}`,
220
227
  }));
221
228
  };
222
229
  const handleCli = (current: Readonly<Record<string, number>>): boolean => {
@@ -226,7 +233,7 @@ export function baselineRatchet(args: {
226
233
  return true;
227
234
  }
228
235
  if (cliArgs.includes("--no-baseline")) {
229
- console.log(" Baseline-Vergleich uebersprungen (--no-baseline).");
236
+ console.log(" Baseline comparison skipped (--no-baseline).");
230
237
  return true;
231
238
  }
232
239
  return false;
@@ -362,7 +369,7 @@ export type RepoCheck = {
362
369
  };
363
370
 
364
371
  const VACUOUS_MESSAGE =
365
- "0 Dateien gescannt, obwohl die Ziel-Repos im Checkout liegendie Globs greifen nicht.";
372
+ "0 files scanned even though the target repos are in the checkout the globs are not matching.";
366
373
 
367
374
  /** Standalone-`main()` guards (their own scan/walk, no shared ts-morph project) run in-process through this. */
368
375
  export async function runRepoChecks(
@@ -435,7 +442,7 @@ export function explainGuards(
435
442
  for (const { root, source } of roots) {
436
443
  const rootScan = scanByAbsPath.get(root.absPath);
437
444
  if (!rootScan) {
438
- lines.push(` ${root.name} [${source}] — außerhalb kinds`);
445
+ lines.push(` ${root.name} [${source}] — outside kinds`);
439
446
  continue;
440
447
  }
441
448
  // Exact-path lookup, never a re-glob: `project.getSourceFiles(globs)`
@@ -443,13 +450,61 @@ export function explainGuards(
443
450
  // `[id].tsx` is then a broken (or pathologically slow) character class.
444
451
  const fileCount = rootScan.files.filter((f) => project.getSourceFile(f) !== undefined).length;
445
452
  lines.push(
446
- ` ${root.name} [${source}] ${fileCount} Dateien (Source-Surface ${rootScan.sourceSurface})`,
453
+ ` ${root.name} [${source}] ${fileCount} files (source surface ${rootScan.sourceSurface})`,
447
454
  );
448
455
  }
449
456
  }
450
457
  return lines;
451
458
  }
452
459
 
460
+ function guardKitVersion(): string {
461
+ const pkgPath = resolve(import.meta.dir, "../../package.json");
462
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { readonly version?: string };
463
+ return pkg.version ?? "0.0.0";
464
+ }
465
+
466
+ /**
467
+ * Pure preflight verdict, injectable for tests: which of the two globally
468
+ * silent-green cases (infra#2863) applies, if any. Not to be confused with a
469
+ * single guard finding no target repos — that stays a per-guard `skipped`
470
+ * line in `reportResults`.
471
+ */
472
+ export function guardKitPreflightError(guardCount: number, rootCount: number): string | undefined {
473
+ if (rootCount === 0) {
474
+ return "No repo root resolved — checkout has no package.json with a kumiko.json/src layout above cwd.";
475
+ }
476
+ if (guardCount === 0) {
477
+ return "No guards registered — the runner's guard array is empty.";
478
+ }
479
+ return undefined;
480
+ }
481
+
482
+ export type GuardKitBannerDeps = {
483
+ readonly resolution?: RootResolution;
484
+ };
485
+
486
+ /**
487
+ * Shared entrypoint banner for the three bin/run-*.ts runners: fails closed
488
+ * on the two globally-empty cases above, otherwise prints the header every
489
+ * run starts with, before the first guard result. Not used by `--explain`,
490
+ * which already prints its own "Repo: ..." header.
491
+ */
492
+ export function printGuardKitBanner(
493
+ guardCount: number,
494
+ project?: Project,
495
+ deps: GuardKitBannerDeps = {},
496
+ ): void {
497
+ const { roots } = deps.resolution ?? explainRepoRoots();
498
+ const error = guardKitPreflightError(guardCount, roots.length);
499
+ if (error !== undefined) {
500
+ console.error(error);
501
+ process.exit(1);
502
+ }
503
+ console.log(`kumiko-guards ${guardKitVersion()} - ${guardCount} guards registered`);
504
+ console.log(`Roots: ${roots.map((r) => r.root.name).join(", ")} (${roots.length})`);
505
+ if (project) console.log(`Project: ${project.getSourceFiles().length} files`);
506
+ }
507
+
453
508
  /**
454
509
  * Vacuity floor for guards with their own `main()`.
455
510
  *
@@ -483,11 +538,11 @@ export function reportResults(results: readonly RunResult[]): number {
483
538
  // Not applicable does not mean checked — that must stay visible,
484
539
  // otherwise a standalone run reads like a complete one.
485
540
  const scope = r.notApplicable
486
- ? " — übersprungen, Ziel-Repos nicht im Checkout"
487
- : ` (${r.matchedFiles ?? 0} Dateien)`;
541
+ ? " — skipped, target repos not in checkout"
542
+ : ` (${r.matchedFiles ?? 0} files)`;
488
543
  const frozen =
489
544
  r.frozenFindings && r.frozenFindings > 0
490
- ? ` — ${r.frozenFindings} eingefrorene Security-Findings (Baseline)`
545
+ ? ` — ${r.frozenFindings} frozen security findings (baseline)`
491
546
  : "";
492
547
  console.log(` ✓ ${r.name} (${r.ms}ms)${scope}${frozen}`);
493
548
  for (const w of r.warnings ?? []) {
@@ -507,14 +562,14 @@ export function reportResults(results: readonly RunResult[]): number {
507
562
  }
508
563
  if (r.violatingRoots && r.violatingRoots.length > 0) {
509
564
  console.error(
510
- ` 0 Quelldateien in: ${r.violatingRoots.join(", ")} — kumiko.json deklariert sourceRoots, der Scope "source" liefert dort nichts.`,
565
+ ` 0 source files in: ${r.violatingRoots.join(", ")} — kumiko.json declares sourceRoots, scope "source" yields nothing there.`,
511
566
  );
512
567
  console.error(
513
- " Manifest (kumiko.json) oder Checkout prüfen; ohne Manifest gilt der abgeleitete Fallback packages/*/src bzw. src/.",
568
+ " Check the manifest (kumiko.json) or the checkout; without a manifest the derived fallback packages/*/src or src/ applies.",
514
569
  );
515
570
  }
516
571
  if (r.security) {
517
- console.error(` [security] Funde ohne Baseline-Deckung sind immer FAIL.`);
572
+ console.error(` [security] Findings without baseline coverage always FAIL.`);
518
573
  }
519
574
  for (const v of r.outcome?.violations ?? []) {
520
575
  console.error(` ${v.file}:${v.line} ${v.message}`);
package/src/_lib/qn.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Shared helpers für Qualified-Names (QN, z.B. "tickets:write:close").
3
+ * Extrahiert aus guard-action-wiring.ts + guard-write-handler-qns.ts, die
4
+ * beide eine byte-identische toKebab-Implementierung inline hatten.
5
+ */
6
+
7
+ export function toKebab(s: string): string {
8
+ return s
9
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
10
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
11
+ .toLowerCase();
12
+ }
13
+
14
+ // Regex für ein gültiges Write-Handler-QN.
15
+ // Erlaubt 3 Segmente (feature:write:handler) oder 4+ (feature:write:entity:verb, feature:write:domain:entity:verb).
16
+ // Akzeptiert camelCase + kebab-case — Normalisierung auf kebab erfolgt vor Manifest-Match.
17
+ // Jedes Segment einzeln validiert (kein ":" im Segment-Zeichensatz) — sonst
18
+ // akzeptiert die letzte Segment-Gruppe trailing/doppelte Colons wie
19
+ // "tickets:write:close:" als valide.
20
+ export const VALID_QN_RE =
21
+ /^[a-zA-Z][a-zA-Z0-9-]*:write:[a-zA-Z][a-zA-Z0-9-]*(:[a-zA-Z][a-zA-Z0-9-]*)*$/;
@@ -28,7 +28,7 @@ export function writeSecurityBaselines(guards: readonly AstGuard[], project: Pro
28
28
  for (const root of roots) {
29
29
  const load = loadSecurityBaseline(root.name, root.absPath);
30
30
  if (load.kind === "invalid") {
31
- console.error(` ✗ Security-Baseline ${load.file}: ${load.reason}`);
31
+ console.error(` ✗ Security baseline ${load.file}: ${load.reason}`);
32
32
  process.exit(1);
33
33
  }
34
34
  hardFailByRepo.set(root.name, load.hardFail);
@@ -38,7 +38,7 @@ export function writeSecurityBaselines(guards: readonly AstGuard[], project: Pro
38
38
  const hardFail = hardFailByRepo.get(root.name) ?? [];
39
39
  const baseline = buildSecurityBaseline(root.name, guardViolations, roots, cwd, hardFail);
40
40
  const path = writeSecurityBaseline(baseline, root.absPath);
41
- console.log(` Security-Baseline geschrieben: ${path} (total ${baseline.total})`);
41
+ console.log(` Security baseline written: ${path} (total ${baseline.total})`);
42
42
  for (const guardName of hardFail) {
43
43
  const count = guardViolations
44
44
  .filter((gv) => gv.guardName === guardName)
@@ -46,7 +46,7 @@ export function writeSecurityBaselines(guards: readonly AstGuard[], project: Pro
46
46
  .filter((v) => locateFinding(v.file, roots, cwd)?.repo === root.name).length;
47
47
  if (count > 0) {
48
48
  console.warn(
49
- ` ! ${root.name}: ${count} Funde von ${guardName} nicht eingefroren (hardFail) — der Guard-Lauf blockiert sie`,
49
+ ` ! ${root.name}: ${count} findings from ${guardName} not frozen (hardFail) — the guard run blocks them`,
50
50
  );
51
51
  }
52
52
  }
@@ -98,18 +98,18 @@ export function loadSecurityBaseline(repo: string, repoDir: string): SecurityBas
98
98
  return {
99
99
  kind: "invalid",
100
100
  file,
101
- reason: `kaputtes JSON: ${e instanceof Error ? e.message : String(e)}`,
101
+ reason: `broken JSON: ${e instanceof Error ? e.message : String(e)}`,
102
102
  };
103
103
  }
104
104
  const parsed = parseSecurityBaseline(raw);
105
105
  if (!parsed) {
106
- return { kind: "invalid", file, reason: "unerwartetes Baseline-Format (format/repo/findings)" };
106
+ return { kind: "invalid", file, reason: "unexpected baseline format (format/repo/findings)" };
107
107
  }
108
108
  if (parsed.repo !== repo) {
109
109
  return {
110
110
  kind: "invalid",
111
111
  file,
112
- reason: `repo-Feld "${parsed.repo}" passt nicht zum Dateinamen "${repo}"`,
112
+ reason: `repo field "${parsed.repo}" does not match the file name "${repo}"`,
113
113
  };
114
114
  }
115
115
  return { kind: "ok", findings: parsed.findings, hardFail: parsed.hardFail ?? [] };
@@ -192,7 +192,7 @@ export function applySecurityBaseline(args: {
192
192
  {
193
193
  file: baseline.file,
194
194
  line: 1,
195
- message: `Security-Baseline unlesbar oder ungültig: ${baseline.reason}. Datei reparieren; ein kaputter Baseline-Stand darf keine Funde freigeben.`,
195
+ message: `Security baseline unreadable or invalid: ${baseline.reason}. Fix the file; a broken baseline must not release any findings.`,
196
196
  },
197
197
  ]);
198
198
  for (const e of allEntries) blockingEntries.push([e.index, e.violation]);
@@ -204,7 +204,7 @@ export function applySecurityBaseline(args: {
204
204
  e.index,
205
205
  {
206
206
  ...e.violation,
207
- message: `${e.violation.message} (Security-Baseline ${repo}: ${guardName} ist fertig migriert keine Baseline-Toleranz)`,
207
+ message: `${e.violation.message} (security baseline ${repo}: ${guardName} finished migratingno baseline tolerance)`,
208
208
  },
209
209
  ]);
210
210
  }
@@ -224,7 +224,7 @@ export function applySecurityBaseline(args: {
224
224
  e.index,
225
225
  {
226
226
  ...e.violation,
227
- message: `${e.violation.message} (Security-Baseline ${repo}: erlaubt=${regression.baseline}, aktuell=${regression.current})`,
227
+ message: `${e.violation.message} (security baseline ${repo}: allowed=${regression.baseline}, current=${regression.current})`,
228
228
  },
229
229
  ]);
230
230
  }
@@ -247,7 +247,7 @@ export function applySecurityBaseline(args: {
247
247
  {
248
248
  file: baseline.file,
249
249
  line: 1,
250
- message: `Security-Baseline unlesbar oder ungültig: ${baseline.reason}. Datei reparieren; ein kaputter Baseline-Stand darf keine Funde freigeben.`,
250
+ message: `Security baseline unreadable or invalid: ${baseline.reason}. Fix the file; a broken baseline must not release any findings.`,
251
251
  },
252
252
  ]);
253
253
  continue;
@@ -267,7 +267,7 @@ export function applySecurityBaseline(args: {
267
267
  {
268
268
  file: join(root.absPath, r.file),
269
269
  line: 1,
270
- message: `Security-Baseline veraltet: ${root.name}/${r.file} erlaubt ${r.baseline}, gefunden ${r.current} — \`--write-security-baseline\` ausführen und committen, sonst deckt der Headroom neue Funde.`,
270
+ message: `Security baseline stale: ${root.name}/${r.file} allows ${r.baseline}, found ${r.current} — run \`--write-security-baseline\` and commit, otherwise the headroom covers new findings.`,
271
271
  },
272
272
  ]);
273
273
  }
package/src/changes.json CHANGED
@@ -1,4 +1,36 @@
1
1
  [
2
+ {
3
+ "version": "0.3.0",
4
+ "type": "improvement",
5
+ "title": "Add a consumer CLI entry so bunx @cosmicdrift/kumiko-guards runs all three suites"
6
+ },
7
+ {
8
+ "version": "0.3.0",
9
+ "type": "fix",
10
+ "title": "Fix two false-positive findings in the Secret-Literal and Thin-Wrappers guards",
11
+ "detail": "The Secret-Literal Guard flagged secret-fallback patterns documented inside comments: only line-start `//` comments were skipped, so a block-comment line (JSDoc-style `*`, `/*`, `*/`) explaining the rule tripped the guard on its own documentation. Block-comment lines are now recognized as comments too. The Thin-Wrappers Guard misread `/regex/.test(x)` as a call to a function literally named `test`, reporting the enclosing function as a thin wrapper around it. That misclassification no longer fires."
12
+ },
13
+ {
14
+ "version": "0.3.0",
15
+ "type": "improvement",
16
+ "title": "Port the last five infra/guards guards",
17
+ "detail": "app-feature-structure, lib-test-coverage, i18n-locale-terminology, feature-integration-tests, and test-stack-drift ported from the private infra/guards package into the public @cosmicdrift/kumiko-guards package. The latter two were rebuilt from ad-hoc infra scripts onto the public RepoCheck pattern; the other three are literal AstGuard ports."
18
+ },
19
+ {
20
+ "version": "0.3.0",
21
+ "type": "improvement",
22
+ "title": "Add startup banner, fail-closed on zero roots/guards, and finish English output"
23
+ },
24
+ {
25
+ "version": "0.2.0",
26
+ "type": "improvement",
27
+ "title": "Migrate remaining framework-semantic guards out of infra/guards"
28
+ },
29
+ {
30
+ "version": "0.2.0",
31
+ "type": "improvement",
32
+ "title": "Add runner parity with the private infra/guards package"
33
+ },
2
34
  {
3
35
  "version": "0.1.1",
4
36
  "type": "fix",