import type { RepositorySourceContext } from './ts-project.js'; export const ENVIRONMENT_DECLARATIONS_SCHEMA = 'service-flow/environment-declarations@1'; export const ENVIRONMENT_DECLARATION_RECORD_CAP = 32; export const EVENT_ENVIRONMENT_KEY_CAP = 16; export const DEFAULT_EVENT_ENVIRONMENT_KEYS = [] as const; export const EVENT_ENVIRONMENT_KEY_ALLOWLIST = DEFAULT_EVENT_ENVIRONMENT_KEYS; export type EventEnvironmentKey = string; export type EnvironmentDeclarationProvenance = | 'env_declaration_manifest' | 'env_declaration_mta' | 'env_declaration_dotenv' | 'env_declaration_dev'; export interface EnvironmentDeclaration { key: EventEnvironmentKey; value: string; provenance: EnvironmentDeclarationProvenance; sourceFile: string; startOffset: number; endOffset: number; } export interface EnvironmentDeclarationsFact { schema: typeof ENVIRONMENT_DECLARATIONS_SCHEMA; allowedKeys: string[]; status: 'complete' | 'ambiguous' | 'not_applicable' | 'incomplete'; reason: string | null; recordCap: typeof ENVIRONMENT_DECLARATION_RECORD_CAP; total: number; shown: number; omitted: number; declarations: EnvironmentDeclaration[]; declarationKeyCounts?: Array<{ key: string; count: number }>; } const allowedProvenance = new Set([ 'env_declaration_manifest', 'env_declaration_mta', 'env_declaration_dotenv', 'env_declaration_dev', ]); const environmentValueLimit = 512; const dynamicEnvironmentValue = /\$\{|\$\(|~\{|\(\(/; const environmentKeyGrammar = /^[A-Z_][A-Z0-9_]{0,63}$/; export function validEventEnvironmentKey(value: string): boolean { return environmentKeyGrammar.test(value); } export function normalizeEventEnvironmentKeys( values: readonly string[] = DEFAULT_EVENT_ENVIRONMENT_KEYS, ): string[] { const unique = [...new Set(values)].sort((left, right) => left < right ? -1 : left > right ? 1 : 0); if (unique.length > EVENT_ENVIRONMENT_KEY_CAP || !unique.every(validEventEnvironmentKey)) throw new Error('invalid_event_environment_keys'); return unique; } function hasControlCharacter(value: string): boolean { for (const character of value) { const code = character.charCodeAt(0); if (code <= 31 || code === 127) return true; } return false; } function record(value: unknown): Record | undefined { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; } function parseJson(value: unknown): unknown { if (typeof value !== 'string') return value; try { return JSON.parse(value) as unknown; } catch { return undefined; } } function scalarValue(raw: string): string | undefined { const value = raw.trim(); if (!value || /^[{[*&!>|]/.test(value)) return undefined; if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1); return /[\r\n]/.test(value) ? undefined : value; } function declaration( key: string, value: unknown, provenance: EnvironmentDeclarationProvenance, sourceFile: string, startOffset: number, allowedKeys: ReadonlySet, ): EnvironmentDeclaration[] { if (!allowedKeys.has(key) || typeof value !== 'string' || !value || value.length > environmentValueLimit || hasControlCharacter(value) || dynamicEnvironmentValue.test(value)) return []; return [{ key: key as EventEnvironmentKey, value, provenance, sourceFile, startOffset, endOffset: startOffset + value.length, }]; } function valueOffset( text: string, value: string, after: number, ): number { const direct = text.indexOf(value, Math.max(0, after)); return direct >= 0 ? direct : Math.max(0, after); } function jsonDeclarations( filePath: string, text: string, keys: readonly string[], allowedKeys: ReadonlySet, ): EnvironmentDeclaration[] { let parsed: unknown; try { parsed = JSON.parse(text) as unknown; } catch { return []; } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return []; const env = (parsed as Record).env; if (!env || typeof env !== 'object' || Array.isArray(env)) return []; return keys.flatMap((key) => { const value = (env as Record)[key]; const keyOffset = text.indexOf(`"${key}"`); const offset = typeof value === 'string' ? valueOffset(text, value, keyOffset + key.length + 2) : keyOffset; return declaration( key, value, 'env_declaration_dev', filePath, Math.max(0, offset), allowedKeys, ); }); } function dotenvDeclarations( filePath: string, text: string, allowedKeys: ReadonlySet, ): EnvironmentDeclaration[] { const values: EnvironmentDeclaration[] = []; let offset = 0; for (const line of text.split(/\n/)) { const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/ .exec(line); const value = match?.[2] === undefined ? undefined : scalarValue(match[2]); if (match?.[1] && value !== undefined) values.push(...declaration( match[1], value, 'env_declaration_dotenv', filePath, offset + valueOffset(line, value, line.indexOf('=') + 1), allowedKeys, )); offset += line.length + 1; } return values; } function indentation(value: string): number { return /^\s*/.exec(value)?.[0].length ?? 0; } function yamlDeclarations( filePath: string, text: string, provenance: EnvironmentDeclarationProvenance, allowedKeys: ReadonlySet, ): EnvironmentDeclaration[] { const values: EnvironmentDeclaration[] = []; let envIndent: number | undefined; let offset = 0; for (const line of text.split(/\n/)) { const trimmed = line.trim(); const indent = indentation(line); if (/^env\s*:\s*(?:#.*)?$/.test(trimmed)) envIndent = indent; else if (envIndent !== undefined && trimmed && !trimmed.startsWith('#')) { if (indent <= envIndent) envIndent = undefined; else { const match = /^([A-Za-z_][A-Za-z0-9_]*)\s*:\s*(.*?)\s*(?:#.*)?$/ .exec(trimmed); const value = match?.[2] === undefined ? undefined : scalarValue(match[2]); if (match?.[1] && value !== undefined) values.push(...declaration( match[1], value, provenance, filePath, offset + valueOffset(line, value, line.indexOf(':') + 1), allowedKeys, )); } } offset += line.length + 1; } return values; } function snapshotDeclarations( filePath: string, text: string, keys: readonly string[], allowedKeys: ReadonlySet, ): EnvironmentDeclaration[] { const name = filePath.split('/').at(-1); if (name === 'nodemon.json') return jsonDeclarations(filePath, text, keys, allowedKeys); if (name === '.env') return dotenvDeclarations(filePath, text, allowedKeys); if (name === 'manifest.yml') return yamlDeclarations( filePath, text, 'env_declaration_manifest', allowedKeys, ); return name === 'mta.yaml' ? yamlDeclarations(filePath, text, 'env_declaration_mta', allowedKeys) : []; } function compareDeclaration( left: EnvironmentDeclaration, right: EnvironmentDeclaration, ): number { const leftKey = `${left.key}\0${left.sourceFile}\0${left.startOffset}`; const rightKey = `${right.key}\0${right.sourceFile}\0${right.startOffset}`; return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; } function declarationIdentityValid( item: Record, allowedKeys: ReadonlySet, ): boolean { return typeof item.key === 'string' && allowedKeys.has(item.key) && typeof item.provenance === 'string' && allowedProvenance.has( item.provenance as EnvironmentDeclarationProvenance, ); } function declarationValueValid(item: Record): boolean { return typeof item.value === 'string' && item.value.length > 0 && item.value.length <= environmentValueLimit && !hasControlCharacter(item.value) && !dynamicEnvironmentValue.test(item.value); } function declarationLocationValid(item: Record): boolean { return typeof item.sourceFile === 'string' && item.sourceFile.length > 0 && Number.isInteger(item.startOffset) && Number(item.startOffset) >= 0 && Number.isInteger(item.endOffset) && Number(item.endOffset) > Number(item.startOffset); } function parsedDeclaration( value: unknown, allowedKeys: ReadonlySet, ): EnvironmentDeclaration | undefined { const item = record(value); if (!item || !declarationIdentityValid(item, allowedKeys) || !declarationValueValid(item) || !declarationLocationValid(item)) return undefined; return item as unknown as EnvironmentDeclaration; } function countsValid( item: Record, declarations: EnvironmentDeclaration[], ): boolean { return item.recordCap === ENVIRONMENT_DECLARATION_RECORD_CAP && Number.isInteger(item.total) && Number(item.total) >= 0 && Number.isInteger(item.shown) && Number(item.shown) >= 0 && Number.isInteger(item.omitted) && Number(item.omitted) >= 0 && Number(item.shown) + Number(item.omitted) === Number(item.total) && Number(item.shown) === declarations.length && declarations.length <= ENVIRONMENT_DECLARATION_RECORD_CAP; } function statusValid( item: Record, declarations: EnvironmentDeclaration[], ): boolean { const distinct = new Map>(); for (const value of declarations) { const values = distinct.get(value.key) ?? new Set(); values.add(value.value); distinct.set(value.key, values); } const ambiguous = [...distinct.values()].some((values) => values.size > 1); if (item.status === 'not_applicable') return item.reason === null && item.total === 0; if (item.status === 'complete') return item.reason === null && item.total === item.shown && Number(item.total) > 0 && !ambiguous; if (item.status === 'ambiguous') return item.reason === 'environment_declaration_values_conflict' && ambiguous && item.omitted === 0; return item.status === 'incomplete' && item.reason === 'environment_declaration_record_cap_exceeded' && Number(item.omitted) > 0; } function parsedAllowedKeys(value: unknown): string[] | undefined { if (!Array.isArray(value) || !value.every((item): item is string => typeof item === 'string')) return undefined; try { const normalized = normalizeEventEnvironmentKeys(value); return normalized.length === value.length && normalized.every((item, index) => item === value[index]) ? normalized : undefined; } catch { return undefined; } } function declarationKeyCountsValid( value: unknown, allowedKeys: ReadonlySet, total: number, ): boolean { if (value === undefined) return true; if (!Array.isArray(value)) return false; const seen = new Set(); let sum = 0; for (const entry of value) { const item = record(entry); if (!item || typeof item.key !== 'string' || !allowedKeys.has(item.key) || seen.has(item.key) || !Number.isInteger(item.count) || Number(item.count) < 0) return false; seen.add(item.key); sum += Number(item.count); } return sum === total; } export function parseEnvironmentDeclarationsFact( value: unknown, ): EnvironmentDeclarationsFact | undefined { const item = record(parseJson(value)); if (!item || item.schema !== ENVIRONMENT_DECLARATIONS_SCHEMA || !Array.isArray(item.declarations)) return undefined; const keys = parsedAllowedKeys(item.allowedKeys); if (!keys) return undefined; const allowedKeys = new Set(keys); if (!declarationKeyCountsValid( item.declarationKeyCounts, allowedKeys, Number(item.total), )) return undefined; const declarations = item.declarations.flatMap((entry) => { const parsed = parsedDeclaration(entry, allowedKeys); return parsed ? [parsed] : []; }); if (declarations.length !== item.declarations.length || !countsValid(item, declarations) || !statusValid(item, declarations)) return undefined; const identities = declarations.map((entry) => `${entry.key}\0${entry.sourceFile}\0${entry.startOffset}\0${entry.endOffset}`); if (new Set(identities).size !== identities.length) return undefined; return { ...item, declarations } as unknown as EnvironmentDeclarationsFact; } export function collectEnvironmentDeclarations( sources: RepositorySourceContext, configuredKeys: readonly string[] = DEFAULT_EVENT_ENVIRONMENT_KEYS, ): EnvironmentDeclarationsFact { const keys = normalizeEventEnvironmentKeys(configuredKeys); const allowedKeys = new Set(keys); const all = sources.entries().flatMap((snapshot) => snapshotDeclarations( snapshot.filePath, snapshot.text, keys, allowedKeys, )) .sort(compareDeclaration); const values = new Set(all.map((item) => `${item.key}\0${item.value}`)); const ambiguous = keys.some((key) => [...values].filter((value) => value.startsWith(`${key}\0`)).length > 1); const declarations = all.slice(0, ENVIRONMENT_DECLARATION_RECORD_CAP); const declarationKeyCounts = keys.map((key) => ({ key, count: all.filter((item) => item.key === key).length, })); return { schema: ENVIRONMENT_DECLARATIONS_SCHEMA, allowedKeys: keys, status: ambiguous ? 'ambiguous' : all.length > ENVIRONMENT_DECLARATION_RECORD_CAP ? 'incomplete' : all.length > 0 ? 'complete' : 'not_applicable', reason: ambiguous ? 'environment_declaration_values_conflict' : all.length > ENVIRONMENT_DECLARATION_RECORD_CAP ? 'environment_declaration_record_cap_exceeded' : null, recordCap: ENVIRONMENT_DECLARATION_RECORD_CAP, total: all.length, shown: declarations.length, omitted: Math.max(0, all.length - declarations.length), declarations, declarationKeyCounts, }; } export function emptyEnvironmentDeclarations( configuredKeys: readonly string[] = DEFAULT_EVENT_ENVIRONMENT_KEYS, ): EnvironmentDeclarationsFact { return { schema: ENVIRONMENT_DECLARATIONS_SCHEMA, allowedKeys: normalizeEventEnvironmentKeys(configuredKeys), status: 'not_applicable', reason: null, recordCap: ENVIRONMENT_DECLARATION_RECORD_CAP, total: 0, shown: 0, omitted: 0, declarations: [], declarationKeyCounts: normalizeEventEnvironmentKeys(configuredKeys) .map((key) => ({ key, count: 0 })), }; }