mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 13:52:51 +00:00
## Summary - Add a searchable catalog of every error ID ZITADEL's gRPC/Connect APIs can return (`content/apis/errors.mdx`), wired into the sidebar next to "gRPC Status Codes" (with a cross-link added there too). There was previously no such reference — error IDs are assigned ad hoc at ~3,800 call sites across the backend. - `scripts/generate-error-reference.ts` scans `internal/**/*.go` and `backend/**/*.go` for `zerrors.Throw*()` call sites, resolves i18n messages, and clusters ~3,200 distinct IDs into ~1,160 distinct causes by (subsystem, gRPC code, message) — so identical causes are merged into one entry instead of repeated ~3,200 times. - Detects real ID collisions (same ID thrown for genuinely different reasons at different call sites) and surfaces them as warnings, rather than silently merging them. - Generates a worked example API response for every cause. - 116 of the highest-impact causes (covering the majority of call sites) carry an explanation written against the actual source rather than the raw message text; the rest carry a mechanically derived explanation — both are visibly labeled by source so the depth of research is transparent. - New `ErrorReference` component renders the catalog as a searchable, collapsible subsystem → gRPC code → cause tree. The generator is a standalone script (`pnpm generate:error-reference` in `apps/docs`), not wired into the docs build's `generate` chain, since scanning the whole Go backend on every docs build would be wasteful. The generated `data.json` and the curated overrides are committed, the same way `content/apis/benchmarks/**/output.json` is already committed despite being externally generated. ## Test plan - [x] `pnpm nx run @zitadel/docs:check-types` passes - [x] `pnpm nx run @zitadel/docs:lint` passes - [x] `pnpm nx run @zitadel/docs:check-links` passes - [x] Verified locally in `pnpm nx run @zitadel/docs:dev`: page renders, tree expands/collapses, search filters by ID/message, collision warnings render correctly (e.g. `AUTHZ-Kl3p0`) --------- Co-authored-by: Rajat Singh <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]> Co-authored-by: Marco A. <[email protected]>
571 lines
22 KiB
TypeScript
571 lines
22 KiB
TypeScript
// Scans the Go backend for zerrors.Throw<Kind>[f]("ID", "message") call
|
||
// sites and emits apps/docs/components/ErrorReference/data.json: a
|
||
// 3-level tree (subsystem -> gRPC kind -> cluster) that the ErrorReference
|
||
// component renders. Regenerate after backend error call sites change:
|
||
// pnpm nx run @zitadel/docs:generate-error-reference (from repo root)
|
||
// pnpm generate:error-reference (from apps/docs)
|
||
// This file is TypeScript/ESM (run via tsx, see package.json) — plain
|
||
// `node` won't execute it without a loader.
|
||
//
|
||
// This is intentionally NOT wired into the Nx `generate` chain (which
|
||
// `dev`/`build`/`lint` all depend on) — scanning 2000+ backend Go files on
|
||
// every docs build would cache-invalidate the whole site on unrelated
|
||
// backend PRs. Output (data.json + overrides) is committed instead, the
|
||
// same way apps/docs/content/apis/benchmarks/**/output.json is committed
|
||
// despite being externally generated.
|
||
|
||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'fs';
|
||
import { join, dirname } from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { execSync } from 'child_process';
|
||
import { globSync } from 'glob';
|
||
import yaml from 'js-yaml';
|
||
import { GRPC_STATUS, THROW_KIND_TO_GRPC_STATUS } from '../lib/grpc-status';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const REPO_ROOT = join(__dirname, '../../..');
|
||
const OUTPUT_PATH = join(__dirname, '../components/ErrorReference/data.json');
|
||
const OVERRIDES_DIR = join(__dirname, 'error-reference-overrides');
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Types (mirrored, informally, by apps/docs/components/ErrorReference/index.tsx)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface RawEntry {
|
||
id: string;
|
||
kind: string; // GRPC_STATUS key
|
||
message: string;
|
||
messageType: 'i18n' | 'literal';
|
||
i18nResolved?: boolean;
|
||
file: string;
|
||
line: number;
|
||
}
|
||
|
||
interface ErrorLocation {
|
||
file: string;
|
||
line: number;
|
||
}
|
||
|
||
interface ErrorIdEntry {
|
||
id: string;
|
||
locations: ErrorLocation[];
|
||
}
|
||
|
||
interface ExampleResponse {
|
||
httpStatus: number;
|
||
grpcCode: number;
|
||
body: {
|
||
code: number;
|
||
message: string;
|
||
details: [{ '@type': string; id: string; message: string }];
|
||
};
|
||
}
|
||
|
||
interface ErrorCluster {
|
||
key: string;
|
||
message: string;
|
||
messageType: 'i18n' | 'literal';
|
||
ids: ErrorIdEntry[];
|
||
why: string;
|
||
whySource: 'curated' | 'i18n-decomposition' | 'defensive-guard-template' | 'generic-fallback';
|
||
example: ExampleResponse;
|
||
collidesWith: { key: string; message: string; subsystem: string }[];
|
||
}
|
||
|
||
interface KindBucket {
|
||
kind: string;
|
||
clusters: ErrorCluster[];
|
||
}
|
||
|
||
interface Subsystem {
|
||
id: string;
|
||
name: string;
|
||
summary: string;
|
||
kinds: KindBucket[];
|
||
}
|
||
|
||
interface ErrorReferenceData {
|
||
meta: {
|
||
generatedAt: string;
|
||
sourceRef: string;
|
||
totalCallSites: number;
|
||
totalIds: number;
|
||
totalClusters: number;
|
||
totalCollisions: number;
|
||
};
|
||
subsystems: Subsystem[];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stage: subsystem mapping (directory prefix -> subsystem id)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const SUBSYSTEMS: { id: string; name: string; summary: string }[] = [
|
||
{
|
||
id: 'command',
|
||
name: 'Command layer',
|
||
summary:
|
||
'internal/command is the write side of ZITADEL’s CQRS/event-sourcing core: nearly every state-changing gRPC/Connect call resolves to a Commands method covering users, orgs, instances, projects, IDPs, policies, providers, quotas, actions/executions, sessions, and groups. Errors cluster into three layers mapped to distinct gRPC codes: InvalidArgument for cheap syntactic validation before any eventstore read, NotFound/AlreadyExists for aggregate-existence checks against replayed write-model state, and PreconditionFailed for state-conditional rules discovered mid-command (not changed, already active, lockouts).',
|
||
},
|
||
{
|
||
id: 'query',
|
||
name: 'Query layer & legacy read models',
|
||
summary:
|
||
'internal/query is the current read side (Postgres projections), alongside the legacy internal/auth/internal/user/internal/view read-model stack it is gradually replacing. Errors here are dominated by NotFound (a projection row doesn’t exist or hasn’t caught up yet) and Internal (SQL statement construction / scan failures).',
|
||
},
|
||
{
|
||
id: 'api',
|
||
name: 'API surface, authn/authz, and identity federation',
|
||
summary:
|
||
'internal/api (gRPC/Connect handlers, REST gateway, login UI), internal/authz (token/permission checks), internal/idp (external identity provider integrations), and internal/webauthn sit at the transport/identity boundary. Errors here span request-shape validation, auth/session failures, and third-party IdP response handling.',
|
||
},
|
||
{
|
||
id: 'storage',
|
||
name: 'Storage/eventstore infrastructure & the new v3 backend',
|
||
summary:
|
||
'internal/eventstore (event append/query, Postgres-backed), internal/static (asset storage), and backend/v3 (the newer hexagonal-architecture, relational-storage-backed rewrite) — infrastructure errors: connection failures, constraint violations, unmarshal guards on stored event payloads.',
|
||
},
|
||
{
|
||
id: 'scim',
|
||
name: 'SCIM provisioning',
|
||
summary:
|
||
'internal/api/scim implements SCIM 2.0 user/group provisioning. Errors mirror the SCIM spec’s own error model (invalid filters, invalid paths, bulk-operation limits) more than ZITADEL’s general gRPC error shape.',
|
||
},
|
||
{
|
||
id: 'notification',
|
||
name: 'Notification channels',
|
||
summary:
|
||
'internal/notification renders and sends emails/SMS (templates, SMTP/Twilio/webhook providers). Errors are mostly provider-configuration and template-rendering failures.',
|
||
},
|
||
{
|
||
id: 'crypto',
|
||
name: 'Cryptography & key management',
|
||
summary:
|
||
'internal/crypto covers encryption, hashing, and key/secret generation used throughout the rest of the backend. Errors here are almost entirely configuration/algorithm mismatches, not runtime request data.',
|
||
},
|
||
{
|
||
id: 'domain',
|
||
name: 'Domain value objects, legacy event payloads, and misc infra',
|
||
summary:
|
||
'internal/domain (value-object validation), internal/repository (event definitions/unmarshalling for most aggregates), internal/execution, and other infra that doesn’t fit the groups above. A large share of this group is Internal-coded defensive guards inside event-unmarshalling code that only fire on event-schema corruption, not from ordinary API usage.',
|
||
},
|
||
];
|
||
|
||
function subsystemForPath(relPath: string): string {
|
||
if (relPath.startsWith('internal/api/scim/')) return 'scim';
|
||
if (relPath.startsWith('internal/notification/')) return 'notification';
|
||
if (relPath.startsWith('internal/crypto/')) return 'crypto';
|
||
if (
|
||
relPath.startsWith('internal/eventstore/') ||
|
||
relPath.startsWith('internal/static/') ||
|
||
relPath.startsWith('backend/v3/')
|
||
)
|
||
return 'storage';
|
||
if (
|
||
relPath.startsWith('internal/api/') ||
|
||
relPath.startsWith('internal/authz/') ||
|
||
relPath.startsWith('internal/idp/') ||
|
||
relPath.startsWith('internal/webauthn/')
|
||
)
|
||
return 'api';
|
||
if (
|
||
relPath.startsWith('internal/query/') ||
|
||
relPath.startsWith('internal/auth/') ||
|
||
relPath.startsWith('internal/user/') ||
|
||
relPath.startsWith('internal/view/') ||
|
||
relPath.includes('/repository/view/') // legacy view-model repos for org/project/iam/user aggregates
|
||
)
|
||
return 'query';
|
||
if (relPath.startsWith('internal/command/')) return 'command';
|
||
// internal/repository/{org,instance,project} hold the event definitions
|
||
// (including unmarshal-guard errors) for the aggregates internal/command
|
||
// writes to — they're the write model's event payloads, not a read model,
|
||
// so route them to 'command' too. ID prefix isn't a reliable signal for
|
||
// this: internal/repository/instance mostly throws INST-, not INSTANCE-,
|
||
// and internal/repository/project also throws APPLICATION-/API-/OIDC-/
|
||
// SAML-/USER-, not just PROJECT-.
|
||
if (
|
||
relPath.startsWith('internal/repository/org/') ||
|
||
relPath.startsWith('internal/repository/instance/') ||
|
||
relPath.startsWith('internal/repository/project/')
|
||
)
|
||
return 'command';
|
||
return 'domain';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stage: scan .go files for zerrors.Throw*() call sites
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const KIND_ALIASES: Record<string, keyof typeof GRPC_STATUS> = {
|
||
...THROW_KIND_TO_GRPC_STATUS,
|
||
Error: 'UNKNOWN', // ThrowError always creates KindUnknown (see internal/zerrors/zerror.go)
|
||
};
|
||
|
||
const ID_SHAPE = /^[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]+$/;
|
||
const CALL_RE = /zerrors\.Throw([A-Z][A-Za-z]*?)(f)?\(/g;
|
||
|
||
// Reads up to 2 top-level (paren-depth-1) double-quoted string literals
|
||
// starting right after a Throw*( call's opening paren.
|
||
function extractTopLevelStrings(content: string, startIdx: number): string[] {
|
||
const strings: string[] = [];
|
||
let depth = 1;
|
||
let i = startIdx;
|
||
const limit = Math.min(content.length, startIdx + 4000);
|
||
while (i < limit && depth > 0 && strings.length < 2) {
|
||
const ch = content[i];
|
||
if (ch === '"' && depth === 1) {
|
||
let j = i + 1;
|
||
let raw = '';
|
||
while (j < content.length && content[j] !== '"') {
|
||
if (content[j] === '\\') {
|
||
raw += content[j] + content[j + 1];
|
||
j += 2;
|
||
} else {
|
||
raw += content[j];
|
||
j++;
|
||
}
|
||
}
|
||
try {
|
||
strings.push(JSON.parse('"' + raw + '"'));
|
||
} catch {
|
||
strings.push(raw);
|
||
}
|
||
i = j + 1;
|
||
continue;
|
||
}
|
||
if (ch === '(') depth++;
|
||
else if (ch === ')') depth--;
|
||
i++;
|
||
}
|
||
return strings;
|
||
}
|
||
|
||
function lineOf(content: string, idx: number): number {
|
||
let line = 1;
|
||
for (let i = 0; i < idx; i++) if (content[i] === '\n') line++;
|
||
return line;
|
||
}
|
||
|
||
// True if `idx` is textually preceded, on its own source line, by a `//`
|
||
// comment marker — i.e. the call site is commented out. This is a line-level
|
||
// heuristic, not real Go parsing: it doesn't know whether an earlier `//` on
|
||
// the line sits inside a string literal, and it doesn't handle `/* */` block
|
||
// comments. Neither case is a realistic way to write commented-out Go code,
|
||
// so this is enough to skip the actual failure mode (a fully commented-out
|
||
// `// zerrors.ThrowX(...)` line) without the cost of a real parser.
|
||
function isCommentedOut(content: string, idx: number): boolean {
|
||
const lineStart = content.lastIndexOf('\n', idx) + 1;
|
||
return content.slice(lineStart, idx).includes('//');
|
||
}
|
||
|
||
function scanFile(absPath: string, relPath: string): RawEntry[] {
|
||
const content = readFileSync(absPath, 'utf8');
|
||
const entries: RawEntry[] = [];
|
||
let m: RegExpExecArray | null;
|
||
CALL_RE.lastIndex = 0;
|
||
while ((m = CALL_RE.exec(content))) {
|
||
if (isCommentedOut(content, m.index)) continue;
|
||
const kindRaw = m[1];
|
||
const grpcKey = KIND_ALIASES[kindRaw];
|
||
if (!grpcKey) continue; // not a real Throw<Kind> (defensive)
|
||
const strings = extractTopLevelStrings(content, CALL_RE.lastIndex);
|
||
if (strings.length < 2 || !ID_SHAPE.test(strings[0])) continue;
|
||
const [id, message] = strings;
|
||
const messageType: 'i18n' | 'literal' = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/.test(message)
|
||
? 'i18n'
|
||
: 'literal';
|
||
entries.push({
|
||
id,
|
||
kind: grpcKey,
|
||
message,
|
||
messageType,
|
||
file: relPath,
|
||
line: lineOf(content, m.index),
|
||
});
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stage: i18n resolution
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function loadYaml(relPath: string): any {
|
||
try {
|
||
return yaml.load(readFileSync(join(REPO_ROOT, relPath), 'utf8'));
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
const I18N_BUNDLES = [
|
||
loadYaml('internal/static/i18n/en.yaml'),
|
||
loadYaml('internal/api/ui/login/static/i18n/en.yaml'),
|
||
loadYaml('internal/notification/static/i18n/en.yaml'),
|
||
];
|
||
|
||
function resolveI18n(key: string): string | undefined {
|
||
for (const bundle of I18N_BUNDLES) {
|
||
let node: any = bundle;
|
||
for (const segment of key.split('.')) {
|
||
if (node == null || typeof node !== 'object') {
|
||
node = undefined;
|
||
break;
|
||
}
|
||
node = node[segment];
|
||
}
|
||
if (typeof node === 'string') return node;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Stage: "why" generation (mechanical baseline — curated overrides applied later)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const GUARD_PATTERNS = [/^unable to unmarshal /i, /^reduce\.wrong\.event\.type/i, /^cannot scan type /i, /^could not unmarshal /i];
|
||
|
||
const KIND_WHY_VERB: Record<string, (subject: string) => string> = {
|
||
NOT_FOUND: (s) => `${s} could not be found.`,
|
||
ALREADY_EXISTS: (s) => `${s} already exists.`,
|
||
INVALID_ARGUMENT: (s) => `The request was rejected because ${s.toLowerCase()} is missing or invalid.`,
|
||
FAILED_PRECONDITION: (s) => `The request was rejected because of ${s.toLowerCase()} — the current state doesn’t allow this operation.`,
|
||
PERMISSION_DENIED: (s) => `The caller is authenticated but not authorized: ${s.toLowerCase()}.`,
|
||
UNAUTHENTICATED: (s) => `The caller could not be authenticated: ${s.toLowerCase()}.`,
|
||
RESOURCE_EXHAUSTED: (s) => `A limit was exceeded: ${s.toLowerCase()}.`,
|
||
UNIMPLEMENTED: (s) => `${s} is not supported on this code path.`,
|
||
INTERNAL: (s) => `Internal failure related to ${s.toLowerCase()}.`,
|
||
};
|
||
|
||
function humanizeI18nKey(key: string): string {
|
||
const segments = key.split('.').filter((s) => s.toLowerCase() !== 'errors');
|
||
const words = segments
|
||
.map((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2'))
|
||
.join(' ')
|
||
.trim();
|
||
return words.length > 0 ? `The ${words.toLowerCase()}` : 'This condition';
|
||
}
|
||
|
||
function mechanicalWhy(kind: string, message: string, messageType: 'i18n' | 'literal', i18nResolved: boolean): {
|
||
why: string;
|
||
whySource: ErrorCluster['whySource'];
|
||
} {
|
||
if (GUARD_PATTERNS.some((re) => re.test(message))) {
|
||
return {
|
||
why: 'Internal defensive guard around event/projection unmarshalling — not expected to occur from ordinary API usage. Indicates event-schema corruption or a routing bug if it does.',
|
||
whySource: 'defensive-guard-template',
|
||
};
|
||
}
|
||
// Only decompose while `message` is still the raw dotted key (unresolved).
|
||
// Once resolveI18n() has replaced it with the actual translated English
|
||
// sentence, decomposing that sentence as if it were a key produces mangled
|
||
// prose (e.g. "Errors.Internal" -> "An internal error occurred" fed back
|
||
// through humanizeI18nKey reads as "the an internal error occurred").
|
||
if (messageType === 'i18n' && !i18nResolved) {
|
||
const subject = humanizeI18nKey(message);
|
||
const verb = KIND_WHY_VERB[kind];
|
||
return {
|
||
why: verb ? verb(subject) : `${subject} (${kind}).`,
|
||
whySource: 'i18n-decomposition',
|
||
};
|
||
}
|
||
return {
|
||
why: `${kind} error: “${message}” — see the linked source location(s) for the exact trigger condition.`,
|
||
whySource: 'generic-fallback',
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Main pipeline
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function main() {
|
||
const files = globSync(['internal/**/*.go', 'backend/**/*.go'], {
|
||
cwd: REPO_ROOT,
|
||
ignore: ['**/*_test.go'],
|
||
});
|
||
|
||
const rawEntries: RawEntry[] = [];
|
||
for (const relPath of files) {
|
||
const abs = join(REPO_ROOT, relPath);
|
||
rawEntries.push(...scanFile(abs, relPath));
|
||
}
|
||
|
||
// Resolve i18n text used for clustering/display, without discarding the raw key.
|
||
for (const e of rawEntries) {
|
||
if (e.messageType === 'i18n') {
|
||
const resolved = resolveI18n(e.message);
|
||
e.i18nResolved = resolved !== undefined;
|
||
if (resolved !== undefined) e.message = resolved;
|
||
}
|
||
}
|
||
|
||
// Cluster by (subsystem, kind, trimmed message).
|
||
const clusterMap = new Map<
|
||
string,
|
||
{
|
||
subsystem: string;
|
||
kind: string;
|
||
message: string;
|
||
messageType: 'i18n' | 'literal';
|
||
i18nResolved: boolean;
|
||
entries: RawEntry[];
|
||
}
|
||
>();
|
||
for (const e of rawEntries) {
|
||
const subsystem = subsystemForPath(e.file);
|
||
const message = e.message.trim();
|
||
const key = `${subsystem}|${e.kind}|${message}`;
|
||
if (!clusterMap.has(key)) {
|
||
clusterMap.set(key, {
|
||
subsystem,
|
||
kind: e.kind,
|
||
message,
|
||
messageType: e.messageType,
|
||
i18nResolved: e.i18nResolved ?? false,
|
||
entries: [],
|
||
});
|
||
}
|
||
clusterMap.get(key)!.entries.push(e);
|
||
}
|
||
|
||
// Collision detection: which cluster keys does each raw id appear in?
|
||
const idToClusterKeys = new Map<string, Set<string>>();
|
||
for (const [key, c] of clusterMap) {
|
||
for (const e of c.entries) {
|
||
if (!idToClusterKeys.has(e.id)) idToClusterKeys.set(e.id, new Set());
|
||
idToClusterKeys.get(e.id)!.add(key);
|
||
}
|
||
}
|
||
|
||
// Load curated overrides (clusterKey -> { why }).
|
||
const overrides = new Map<string, { why: string }>();
|
||
if (existsSync(OVERRIDES_DIR)) {
|
||
for (const f of readdirSync(OVERRIDES_DIR).filter((f: string) => f.endsWith('.json'))) {
|
||
const parsed = JSON.parse(readFileSync(join(OVERRIDES_DIR, f), 'utf8'));
|
||
for (const [key, val] of Object.entries<{ why: string }>(parsed)) overrides.set(key, val);
|
||
}
|
||
}
|
||
|
||
const subsystems: Subsystem[] = SUBSYSTEMS.map((s) => ({ ...s, kinds: [] }));
|
||
const subsystemIndex = new Map(subsystems.map((s) => [s.id, s]));
|
||
let totalClusters = 0;
|
||
let totalCollisions = 0;
|
||
|
||
for (const [key, c] of clusterMap) {
|
||
totalClusters++;
|
||
const idMap = new Map<string, ErrorIdEntry>();
|
||
for (const e of c.entries) {
|
||
if (!idMap.has(e.id)) idMap.set(e.id, { id: e.id, locations: [] });
|
||
idMap.get(e.id)!.locations.push({ file: e.file, line: e.line });
|
||
}
|
||
const ids = [...idMap.values()].sort((a, b) => a.id.localeCompare(b.id));
|
||
|
||
// A cluster key already bakes in (subsystem, kind, message), so the same
|
||
// id turning up under a different key is only a *real* collision — i.e.
|
||
// the id means something genuinely different depending on call site — if
|
||
// the message actually differs. Without this check, an id copy-pasted
|
||
// verbatim into files that happen to sit in two different subsystems
|
||
// would be flagged as colliding with itself.
|
||
const collidesWith: ErrorCluster['collidesWith'] = [];
|
||
const seenCollisionKeys = new Set<string>();
|
||
for (const idEntry of ids) {
|
||
for (const otherKey of idToClusterKeys.get(idEntry.id)!) {
|
||
if (otherKey === key || seenCollisionKeys.has(otherKey)) continue;
|
||
const other = clusterMap.get(otherKey)!;
|
||
if (other.message === c.message) continue;
|
||
seenCollisionKeys.add(otherKey);
|
||
collidesWith.push({ key: otherKey, message: other.message, subsystem: other.subsystem });
|
||
}
|
||
}
|
||
if (collidesWith.length > 0) totalCollisions++;
|
||
|
||
const override = overrides.get(key);
|
||
const mech = mechanicalWhy(c.kind, c.message, c.messageType, c.i18nResolved);
|
||
const why = override?.why ?? mech.why;
|
||
const whySource: ErrorCluster['whySource'] = override ? 'curated' : mech.whySource;
|
||
|
||
const status = GRPC_STATUS[c.kind];
|
||
const repId = ids[0].id;
|
||
// The server always appends " (ID)" to the top-level message (see
|
||
// internal/api/grpc/gerrors/zitadel_errors.go: msg := key; msg += " (" + id + ")").
|
||
// The nested details[].message is the plain text without that suffix.
|
||
const example: ExampleResponse = {
|
||
httpStatus: status.httpStatus,
|
||
grpcCode: status.code,
|
||
body: {
|
||
code: status.code,
|
||
message: `${c.message} (${repId})`,
|
||
details: [{ '@type': 'type.googleapis.com/zitadel.v1.ErrorDetail', id: repId, message: c.message }],
|
||
},
|
||
};
|
||
|
||
const cluster: ErrorCluster = {
|
||
key,
|
||
message: c.message,
|
||
messageType: c.messageType,
|
||
ids,
|
||
why,
|
||
whySource,
|
||
example,
|
||
collidesWith,
|
||
};
|
||
|
||
const subsystem = subsystemIndex.get(c.subsystem)!;
|
||
let bucket = subsystem.kinds.find((k) => k.kind === c.kind);
|
||
if (!bucket) {
|
||
bucket = { kind: c.kind, clusters: [] };
|
||
subsystem.kinds.push(bucket);
|
||
}
|
||
bucket.clusters.push(cluster);
|
||
}
|
||
|
||
// Sort clusters within each kind bucket by size descending (most common first),
|
||
// then sort kind buckets within each subsystem by total id count descending.
|
||
for (const s of subsystems) {
|
||
for (const bucket of s.kinds) {
|
||
bucket.clusters.sort((a, b) => b.ids.length - a.ids.length || a.message.localeCompare(b.message));
|
||
}
|
||
s.kinds.sort((a, b) => {
|
||
const aCount = a.clusters.reduce((n, c) => n + c.ids.length, 0);
|
||
const bCount = b.clusters.reduce((n, c) => n + c.ids.length, 0);
|
||
return bCount - aCount;
|
||
});
|
||
}
|
||
subsystems.sort((a, b) => {
|
||
const count = (s: Subsystem) => s.kinds.reduce((n, k) => n + k.clusters.reduce((m, c) => m + c.ids.length, 0), 0);
|
||
return count(b) - count(a);
|
||
});
|
||
|
||
let sourceRef = 'main';
|
||
try {
|
||
sourceRef = execSync('git rev-parse HEAD', { cwd: REPO_ROOT }).toString().trim();
|
||
} catch {
|
||
// best-effort only
|
||
}
|
||
|
||
const data: ErrorReferenceData = {
|
||
meta: {
|
||
generatedAt: new Date().toISOString(),
|
||
sourceRef,
|
||
totalCallSites: rawEntries.length,
|
||
totalIds: idToClusterKeys.size,
|
||
totalClusters,
|
||
totalCollisions,
|
||
},
|
||
subsystems,
|
||
};
|
||
|
||
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
|
||
writeFileSync(OUTPUT_PATH, JSON.stringify(data));
|
||
|
||
console.log(`[error-reference] ${files.length} files scanned`);
|
||
console.log(`[error-reference] ${rawEntries.length} call sites, ${idToClusterKeys.size} distinct IDs`);
|
||
console.log(`[error-reference] ${totalClusters} clusters, ${totalCollisions} with a real collision`);
|
||
console.log(`[error-reference] ${overrides.size} curated overrides applied`);
|
||
console.log(`[error-reference] wrote ${OUTPUT_PATH}`);
|
||
}
|
||
|
||
main();
|