File size: 10,505 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
#!/usr/bin/env node
// scripts/check/check-docs-symbols.mjs
// Gate anti-alucinação (docs → código): toda referência a uma rota `/api/...` dentro de
// docs/**/*.md deve resolver para um `route.ts` real em src/app/api/. Pega endpoint
// INVENTADO/obsoleto que a IA escreve em docs/PRs descrevendo uma rota que não existe —
// o padrão recorrente das PRs de docs (ex.: oyi77) que fabricam endpoints/APIs.
//
// Complementa os outros gates anti-alucinação:
//   - check-fetch-targets.mjs  : fetch("/api/...") na UI → route.ts (código → código)
//   - check-openapi-routes.mjs : path da openapi.yaml → route.ts (spec → código)
//   - este gate                : /api/... na prosa/markdown → route.ts (docs → código)
//
// LOW-NOISE por design: escopo APENAS a paths de rota `/api/...` (sinal mais alto).
// Tudo que é ruído conhecido (superfície proxy OpenAI-compat, refs a arquivos-fonte,
// APIs upstream de terceiros, placeholders) vai para IGNORE com justificativa, NÃO para
// a allowlist. A allowlist congela só drift REAL pré-existente de docs.
// Stale-enforcement (6A.3): entrada em KNOWN_STALE_DOC_REFS que não suprime nenhum miss
// real → gate falha com instrução de remoção (evita furo de regressão silencioso).
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";

const ROOT = process.cwd();
const DOCS = path.join(ROOT, "docs");
const API = path.join(ROOT, "src/app/api");

// Padrões que NÃO são rotas internas do OmniRoute (ruído estrutural, não drift).
// Adicione aqui (com justificativa) em vez da allowlist quando uma categoria gera
// falsos positivos — a allowlist é só para endpoints stale REAIS.
const IGNORE = [
  /^\/api\/v1\//, // superfície OpenAI-compat (proxy), não rota interna
  /^\/api\/v1beta\//, // superfície Gemini-compat (proxy)
  /^\/api\/v0\//, // APIs upstream de terceiros citadas em docs de pesquisa
  /^\/api\/v2\//, // idem (deployments etc.)
  /^\/api\/(organizations|map-image|graphql|gql)\b/, // APIs de provedores externos documentadas
  /your-/i, // placeholder de exemplo
  /example/i, // placeholder de exemplo
  /\.{3}/, // placeholder "..."
  /\{\}/, // placeholder de param vazio
  /_(POST|GET|PUT|DELETE|PATCH)$/, // refs estilo trace de rede (gql_POST)
];

// Refs a ARQUIVOS-FONTE, não a URLs (ex.: src/app/api/.../route.ts citado em prosa).
// O gate só valida URLs de rota, não caminhos de arquivo.
function isFileRef(p) {
  return /\.(ts|tsx|js|mjs|jsx)$/.test(p) || /\/route$/.test(p);
}

// Refs a `/api/...` que NÃO resolvem para rota real, congeladas para triagem
// (catraca: bloqueia QUALQUER nova ref inventada em docs). Estas são achados REAIS de
// drift/alucinação em docs pré-existentes — cada uma precisa de: criar a rota, corrigir
// o path na doc, ou remover a menção. NÃO adicione novas aqui sem justificativa — esse
// é o ponto do gate. Issues de tracking devem ser abertas para cada cluster.
export const KNOWN_STALE_DOC_REFS = new Set([
  // docs/reference/API_REFERENCE.md — guardrails/shadow doc-fiction RESOLVED in #3496:
  // GET /api/guardrails + POST /api/guardrails/test are now REAL routes (wrapping the
  // existing guardrailRegistry); the fictional enable/disable/logs rows and the entire
  // shadow table were removed from the doc (shadow A-B comparison is combo-config +
  // /api/combos/metrics). No allowlist entries needed for these anymore.
  // docs/research/DISCOVERY_TOOL_DESIGN.md — design doc de feature NÃO implementada
  // (Phase 2). Refs INTENCIONAIS: o doc agora traz um banner "⚠️ Not yet implemented
  // — Phase 2" acima da tabela de endpoints. Mantidos aqui até a feature existir. — #3498
  "/api/discovery/results",
  "/api/discovery/results/:id",
  "/api/discovery/scan",
  "/api/discovery/verify/:id",
  // docs/reference/ENVIRONMENT.md — endpoint UPSTREAM do provedor Blackbox Web,
  // citado na descrição de env var (não é rota do OmniRoute):
  "/api/chat",
  // docs/ops/TUNNELS_GUIDE.md — a doc afirma EXPLICITAMENTE que este endpoint NÃO
  // existe ("There is no central /api/settings/tunnels endpoint"); menção pedagógica:
  "/api/settings/tunnels",
]);

function walk(dir, filter, acc = []) {
  if (!fs.existsSync(dir)) return acc;
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    const p = path.join(dir, e.name);
    if (e.isDirectory()) walk(p, filter, acc);
    else if (filter(e.name)) acc.push(p);
  }
  return acc;
}

export function collectRouteFiles() {
  return new Set(
    walk(API, (n) => /^route\.tsx?$/.test(n)).map((p) =>
      path.relative(ROOT, p).replace(/\\/g, "/")
    )
  );
}

/** Normaliza um segmento dinâmico ({param} / [param] / [...param] / :param) para wildcard. */
function normSeg(seg) {
  if (/^\[\[?\.{3}.+\]\]?$/.test(seg)) return ""; // catch-all [...x] / [[...x]]
  if (/^\{[^}]+\}$/.test(seg) || /^\[[^\]]+\]$/.test(seg) || /^:[^/]+$/.test(seg)) return " ";
  return seg;
}

// /api/providers/{id}/models → src/app/api/providers/[id]/models/route.ts
// Casa por contagem de segmentos OU por prefixo (uma doc pode citar só o prefixo de
// uma rota mais profunda, ex.: /api/auth descrevendo a família /api/auth/login). Qualquer
// segmento dinâmico ([..]/{..}/:..) casa com um segmento dinâmico real.
export function resolveApiDocPathToRoute(apiPath, routeFiles) {
  const segs = apiPath
    .replace(/^\//, "")
    .replace(/[?#].*$/, "")
    .split("/")
    .map(normSeg);
  for (const rf of routeFiles) {
    const rsegs = rf
      .replace(/^src\/app\//, "")
      .replace(/\/route\.tsx?$/, "")
      .split("/");
    const rnorm = rsegs.map((rs) => {
      if (/^\[\[?\.{3}.+\]\]?$/.test(rs)) return ""; // catch-all
      if (/^\[[^\]]+\]$/.test(rs)) return " "; // [param]
      return rs;
    });
    const catchAll = rnorm.includes("");
    const effLen = catchAll ? rnorm.indexOf("") : rnorm.length;
    if (!catchAll && segs.length > rnorm.length) continue; // doc mais profunda que a rota
    if (catchAll && segs.length < effLen) continue;
    const cmpLen = Math.min(segs.length, effLen || rnorm.length);
    let match = true;
    for (let i = 0; i < cmpLen; i++) {
      const rs = rnorm[i];
      if (rs === "") break; // catch-all absorve o resto
      if (!(rs === segs[i] || rs === " " || segs[i] === " ")) {
        match = false;
        break;
      }
    }
    if (match) return true;
  }
  return false;
}

/** Limpa o path capturado: remove pontuação/ênfase de prosa, fecha brackets pendentes. */
function cleanCapturedPath(raw) {
  let p = raw.replace(/[.,:;_)>]+$/, "");
  const ob = (p.match(/\[/g) || []).length;
  const cb = (p.match(/\]/g) || []).length;
  const oc = (p.match(/\{/g) || []).length;
  const cc = (p.match(/\}/g) || []).length;
  if (ob !== cb || oc !== cc) {
    // segmento final truncado pelo regex (bracket aberto sem fechar na prosa) → descarta
    p = p.replace(/\/[^/]*[[{][^/]*$/, "");
  }
  return p.replace(/\/$/, ""); // remove barra final (forma de prefixo)
}

// /api/... só conta como URL quando NÃO é a cauda de um caminho de arquivo-fonte
// (src/lib/api/, @/app/api/, app/api/). O grupo 2 é o path.
const API_PATH_RE = /(^|[^A-Za-z0-9_/])(\/api\/[A-Za-z0-9_\-/{}\[\].:]+)/g;

/** Extrai os paths /api/... distintos de um arquivo markdown (forma URL, não arquivo). */
export function extractDocApiPaths(src) {
  const out = new Set();
  let m;
  API_PATH_RE.lastIndex = 0;
  while ((m = API_PATH_RE.exec(src))) {
    const p = cleanCapturedPath(m[2]);
    if (p && p !== "/api") out.add(p);
  }
  return [...out];
}

/**
 * Núcleo puro/testável.
 * @param {{file: string, paths: string[]}[]} docPathsByFile
 * @param {Set<string>} routeFiles  conjunto de "src/app/api/.../route.ts"
 * @param {Set<string>} allowlist   paths stale congelados
 * @returns {string[]}  misses no formato "file → /api/path"
 */
export function findStaleDocApiRefs(docPathsByFile, routeFiles, allowlist) {
  const misses = [];
  for (const { file, paths } of docPathsByFile) {
    for (const p of paths) {
      if (IGNORE.some((rx) => rx.test(p))) continue;
      if (isFileRef(p)) continue;
      if (allowlist.has(p)) continue;
      if (!resolveApiDocPathToRoute(p, routeFiles)) {
        misses.push(`${file}${p}`);
      }
    }
  }
  return misses;
}

function main() {
  const routeFiles = collectRouteFiles();
  // docs/i18n/** são espelhos auto-gerados das docs canônicas — validar só o canônico
  // evita 40× de ruído duplicado (e os mirrors herdam qualquer fix do canônico).
  // docs/superpowers/** são planos internos de implementação (snapshots históricos
  // de intenção — podem citar rotas planejadas/abandonadas), não claims sobre o
  // código atual; fora do escopo do gate (drift surgiu no ciclo v3.8.18).
  const docFiles = walk(DOCS, (n) => /\.md$/.test(n)).filter((f) => {
    const rel = path.relative(ROOT, f).replace(/\\/g, "/");
    return !rel.startsWith("docs/i18n/") && !rel.startsWith("docs/superpowers/");
  });
  const docPathsByFile = docFiles.map((f) => ({
    file: path.relative(ROOT, f).replace(/\\/g, "/"),
    paths: extractDocApiPaths(fs.readFileSync(f, "utf8")),
  }));

  // Live misses BEFORE allowlist filtering — used for stale-enforcement.
  // The paths (not "file → path" strings) are the unit that the allowlist keys on.
  const allMisses = findStaleDocApiRefs(docPathsByFile, routeFiles, new Set());
  const liveMissPaths = allMisses.map((m) => m.split(" → ")[1]);
  assertNoStale(KNOWN_STALE_DOC_REFS, liveMissPaths, "check-docs-symbols");

  const misses = findStaleDocApiRefs(docPathsByFile, routeFiles, KNOWN_STALE_DOC_REFS);
  if (misses.length) {
    console.error(
      `[check-docs-symbols] ${misses.length} ref(s) /api em docs sem rota real:\n` +
        misses.map((m) => "  ✗ " + m).join("\n") +
        `\n  → crie o route.ts, corrija o path na doc, ou (se for upstream/placeholder)` +
        ` adicione um padrão a IGNORE com justificativa. NÃO adicione à allowlist sem` +
        ` confirmar que é drift pré-existente real.`
    );
    process.exitCode = 1;
  }
  if (!process.exitCode) {
    console.log(
      `[check-docs-symbols] OK — ${docFiles.length} docs canônicas, ` +
        `${routeFiles.size} rotas conhecidas, ${KNOWN_STALE_DOC_REFS.size} stale congeladas`
    );
  }
}

if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();