📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
// Mid-flow checkpoint: should the orchestrator ask the user to raise the budget?
|
||||
// JSON output is the contract the orchestrator parses; markdown is human-only.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { buildBudgetSummary, renderBudgetSummaryMarkdown } from '../lib/budget-summary.mjs';
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.gatePath) {
|
||||
console.error('usage: node scripts/budget-summary.mjs <gate.json> [--format json|markdown] [--no-prompt]');
|
||||
process.exit(1);
|
||||
}
|
||||
const gate = JSON.parse(await readFile(args.gatePath, 'utf-8'));
|
||||
const summary = buildBudgetSummary(gate);
|
||||
// --no-prompt: CI / non-interactive hosts collapse the checkpoint to a logging hop.
|
||||
if (args.noPrompt) {
|
||||
summary.shouldAsk = false;
|
||||
summary.reason = 'forced false via --no-prompt (non-interactive host)';
|
||||
summary.printContract = null;
|
||||
summary.questionText = '';
|
||||
summary.questionPayload = null;
|
||||
summary.options = [];
|
||||
summary.chatPreview = `Audit scope: no question needed — ${summary.reason}.`;
|
||||
summary.exactChatMessage = {
|
||||
body: summary.chatPreview,
|
||||
lineCount: summary.chatPreview.split('\n').length,
|
||||
sha256: createHash('sha256').update(summary.chatPreview).digest('hex'),
|
||||
};
|
||||
summary.printCheck = null;
|
||||
}
|
||||
if (args.format === 'markdown') {
|
||||
process.stdout.write(renderBudgetSummaryMarkdown(summary) + '\n');
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { positional: [], format: 'json' };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--format') out.format = argv[++i];
|
||||
else if (a.startsWith('--format=')) out.format = a.slice('--format='.length);
|
||||
else if (a === '--no-prompt') out.noPrompt = true;
|
||||
else out.positional.push(a);
|
||||
}
|
||||
out.gatePath = out.positional[0];
|
||||
return out;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[budget-summary] FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env node
|
||||
// Regenerates references/{scanner-patterns,candidates}.md from lib/{scanners,gates}/*
|
||||
// metadata. The .mjs files are the source of truth; check-docs-fresh.mjs blocks
|
||||
// PRs where the regenerated output diverges from what's checked in.
|
||||
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { scanners } from '../lib/scanners/index.mjs';
|
||||
import { gates, MAX_CODE_CANDIDATES, GATE_VERSION } from '../lib/gates/index.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REFS = join(HERE, '..', 'references');
|
||||
|
||||
const GENERATED_BANNER =
|
||||
'<!-- THIS FILE IS GENERATED by scripts/build-docs.mjs. Do not edit by hand. -->\n' +
|
||||
'<!-- To change scanner descriptions, edit lib/scanners/*.mjs metadata exports. -->\n' +
|
||||
'<!-- To change gate thresholds, edit lib/gates/*.mjs metadata exports. -->\n\n';
|
||||
|
||||
async function main() {
|
||||
await writeFile(join(REFS, 'scanner-patterns.md'), renderScanners());
|
||||
await writeFile(join(REFS, 'candidates.md'), renderCandidates());
|
||||
console.error('[build-docs] wrote scanner-patterns.md + candidates.md');
|
||||
}
|
||||
|
||||
function renderScanners() {
|
||||
const sorted = scanners.slice().sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
|
||||
let out = GENERATED_BANNER + '# Scanner patterns\n\n';
|
||||
out += 'AST/grep-style scanners run in parallel with metric-driven investigation. They find known anti-patterns. Findings on cold-path or unmappable files are dropped unless the scanner declares `trafficIndependent: true`.\n\n';
|
||||
out += `Total scanners: ${sorted.length}.\n\n`;
|
||||
out += '## Patterns\n\n';
|
||||
for (const s of sorted) {
|
||||
const m = s.metadata;
|
||||
out += `### \`${m.id}\` — ${m.title}\n\n`;
|
||||
out += `- **Severity**: ${m.severity}\n`;
|
||||
out += `- **Billing dimension**: ${m.billingDimension}\n`;
|
||||
out += `- **Traffic-independent**: ${m.trafficIndependent ? 'yes (cold-path findings survive the doctrine drop)' : 'no (cold-path findings get dropped)'}\n\n`;
|
||||
out += `**Description.** ${m.description}\n\n`;
|
||||
out += `**Fix.** ${m.fix}\n\n`;
|
||||
if (m.citations?.length) {
|
||||
out += `**Citations:**\n${m.citations.map((c) => `- \`${c}\``).join('\n')}\n\n`;
|
||||
}
|
||||
out += '---\n\n';
|
||||
}
|
||||
return trimTrailingBlankLine(out);
|
||||
}
|
||||
|
||||
function renderCandidates() {
|
||||
const sorted = gates.slice().sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
|
||||
let out = GENERATED_BANNER + '# Candidate gates\n\n';
|
||||
out += 'The deterministic threshold expressions that turn observability signals into investigation candidates. Pure JS, no LLM. Thresholds live in `lib/gates/*.mjs`.\n\n';
|
||||
out += `Total gates: ${sorted.length}. Budget cap: \`MAX_CODE_CANDIDATES = ${MAX_CODE_CANDIDATES}\`. Gate version: \`${GATE_VERSION}\`.\n\n`;
|
||||
out += '## Gates\n\n';
|
||||
for (const g of sorted) {
|
||||
const m = g.metadata;
|
||||
out += `### \`${m.id}\`\n\n`;
|
||||
out += `- **Threshold**: \`${m.threshold}\`\n`;
|
||||
out += `- **Billing dimension**: ${m.billingDimension}\n`;
|
||||
out += `- **Scope**: ${m.scope}\n`;
|
||||
out += `- **Source citation**: \`${m.sourceCitation}\`\n\n`;
|
||||
out += `${m.description}\n\n`;
|
||||
out += '---\n\n';
|
||||
}
|
||||
return trimTrailingBlankLine(out);
|
||||
}
|
||||
|
||||
function trimTrailingBlankLine(value) {
|
||||
return value.replace(/\n{2,}$/, '\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[build-docs] FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
// Offline citation-library consistency checks. This intentionally does not
|
||||
// fetch URLs; it validates the local allow-list contract used by sanitizers.
|
||||
|
||||
import { loadLibrary, matchesFrameworkVersion } from '../lib/citations.mjs';
|
||||
|
||||
const URL_RE = /^https:\/\/[A-Za-z0-9.-]+\/\S*$/;
|
||||
const SKILL_REF_RE = /^[\w-]+:[\w-]+$/;
|
||||
const BANNED_STALE_URLS = new Set([
|
||||
'https://nextjs.org/docs/app/api-reference/functions/cache-life',
|
||||
'https://nextjs.org/docs/app/api-reference/functions/cache-tag',
|
||||
'https://nextjs.org/docs/app/api-reference/functions/revalidate-tag',
|
||||
'https://nextjs.org/docs/app/api-reference/functions/revalidate-path',
|
||||
'https://nextjs.org/docs/app/api-reference/functions/cache',
|
||||
]);
|
||||
|
||||
async function main() {
|
||||
const lib = await loadLibrary();
|
||||
const errors = [];
|
||||
|
||||
if (!Array.isArray(lib.urls)) errors.push('docs-library.urls must be an array');
|
||||
if (!Array.isArray(lib.ruleSkillRefs)) errors.push('docs-library.ruleSkillRefs must be an array');
|
||||
|
||||
for (const [i, entry] of (lib.urls ?? []).entries()) {
|
||||
const label = `urls[${i}]`;
|
||||
if (!URL_RE.test(entry?.url ?? '')) errors.push(`${label}.url must be an https URL`);
|
||||
if (BANNED_STALE_URLS.has(entry?.url)) {
|
||||
errors.push(`${label}.url uses a stale Next.js docs path: ${entry.url}`);
|
||||
}
|
||||
if (typeof entry.topic !== 'string' || entry.topic.trim() === '') errors.push(`${label}.topic is required`);
|
||||
if (!Array.isArray(entry.appliesTo)) errors.push(`${label}.appliesTo must be an array`);
|
||||
validateFrameworks(entry.applicableFrameworks, `${label}.applicableFrameworks`, errors);
|
||||
}
|
||||
|
||||
const seenRules = new Set();
|
||||
for (const [i, entry] of (lib.ruleSkillRefs ?? []).entries()) {
|
||||
const label = `ruleSkillRefs[${i}]`;
|
||||
const ref = `${entry?.skill ?? ''}:${entry?.rule ?? ''}`;
|
||||
if (!SKILL_REF_RE.test(ref)) errors.push(`${label} must contain skill + rule identifiers`);
|
||||
if (seenRules.has(ref)) errors.push(`${label} duplicate: ${ref}`);
|
||||
seenRules.add(ref);
|
||||
if (typeof (entry.description ?? entry.topic) !== 'string' || (entry.description ?? entry.topic).trim() === '') {
|
||||
errors.push(`${label}.topic or .description is required`);
|
||||
}
|
||||
validateFrameworks(entry.applicableFrameworks, `${label}.applicableFrameworks`, errors);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) console.error(`[check-citations] ${error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(`[check-citations] OK — ${lib.urls.length} URLs, ${lib.ruleSkillRefs.length} skill-rule refs`);
|
||||
}
|
||||
|
||||
function validateFrameworks(patterns, label, errors) {
|
||||
if (!Array.isArray(patterns) || patterns.length === 0) {
|
||||
errors.push(`${label} must be a non-empty array`);
|
||||
return;
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
if (typeof pattern !== 'string' || pattern.trim() === '') {
|
||||
errors.push(`${label} contains an empty pattern`);
|
||||
continue;
|
||||
}
|
||||
if (pattern === '*') continue;
|
||||
// Smoke-check parser coverage with a modern Next version. Unknown framework
|
||||
// patterns are still valid as long as the syntax is recognizable.
|
||||
if (!/^[\w-]+@(?:\*|\d+(?:\.\d+){0,2}|[<>]=?\s*\d+(?:\.\d+){0,2})(?:\s*\|\|\s*[\w-]+@(?:\*|\d+(?:\.\d+){0,2}|[<>]=?\s*\d+(?:\.\d+){0,2}))*$/.test(pattern)) {
|
||||
errors.push(`${label} has unsupported pattern: ${pattern}`);
|
||||
continue;
|
||||
}
|
||||
matchesFrameworkVersion(pattern, 'next', '16.0.0');
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[check-citations] FAILED:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
// CI gate: regenerate the reference docs in memory and diff against what's
|
||||
// checked in. Non-zero exit forces contributors to run build-docs.mjs.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { scanners } from '../lib/scanners/index.mjs';
|
||||
import { gates, MAX_CODE_CANDIDATES, GATE_VERSION } from '../lib/gates/index.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REFS = join(HERE, '..', 'references');
|
||||
|
||||
const GENERATED_BANNER =
|
||||
'<!-- THIS FILE IS GENERATED by scripts/build-docs.mjs. Do not edit by hand. -->\n' +
|
||||
'<!-- To change scanner descriptions, edit lib/scanners/*.mjs metadata exports. -->\n' +
|
||||
'<!-- To change gate thresholds, edit lib/gates/*.mjs metadata exports. -->\n\n';
|
||||
|
||||
async function main() {
|
||||
const expected = {
|
||||
'scanner-patterns.md': renderScanners(),
|
||||
'candidates.md': renderCandidates(),
|
||||
};
|
||||
|
||||
let stale = false;
|
||||
for (const [name, content] of Object.entries(expected)) {
|
||||
let actual;
|
||||
try { actual = await readFile(join(REFS, name), 'utf-8'); }
|
||||
catch {
|
||||
console.error(`[check-docs-fresh] ${name} does not exist. Run \`node scripts/build-docs.mjs\`.`);
|
||||
stale = true;
|
||||
continue;
|
||||
}
|
||||
if (actual !== content) {
|
||||
console.error(`[check-docs-fresh] ${name} is stale. Run \`node scripts/build-docs.mjs\` and commit.`);
|
||||
stale = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (stale) process.exit(1);
|
||||
console.error('[check-docs-fresh] OK — generated docs match source');
|
||||
}
|
||||
|
||||
// MUST stay byte-identical to build-docs.mjs renderers — duplication is the contract.
|
||||
function renderScanners() {
|
||||
const sorted = scanners.slice().sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
|
||||
let out = GENERATED_BANNER + '# Scanner patterns\n\n';
|
||||
out += 'AST/grep-style scanners run in parallel with metric-driven investigation. They find known anti-patterns. Findings on cold-path or unmappable files are dropped unless the scanner declares `trafficIndependent: true`.\n\n';
|
||||
out += `Total scanners: ${sorted.length}.\n\n`;
|
||||
out += '## Patterns\n\n';
|
||||
for (const s of sorted) {
|
||||
const m = s.metadata;
|
||||
out += `### \`${m.id}\` — ${m.title}\n\n`;
|
||||
out += `- **Severity**: ${m.severity}\n`;
|
||||
out += `- **Billing dimension**: ${m.billingDimension}\n`;
|
||||
out += `- **Traffic-independent**: ${m.trafficIndependent ? 'yes (cold-path findings survive the doctrine drop)' : 'no (cold-path findings get dropped)'}\n\n`;
|
||||
out += `**Description.** ${m.description}\n\n`;
|
||||
out += `**Fix.** ${m.fix}\n\n`;
|
||||
if (m.citations?.length) {
|
||||
out += `**Citations:**\n${m.citations.map((c) => `- \`${c}\``).join('\n')}\n\n`;
|
||||
}
|
||||
out += '---\n\n';
|
||||
}
|
||||
return trimTrailingBlankLine(out);
|
||||
}
|
||||
|
||||
function renderCandidates() {
|
||||
const sorted = gates.slice().sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
|
||||
let out = GENERATED_BANNER + '# Candidate gates\n\n';
|
||||
out += 'The deterministic threshold expressions that turn observability signals into investigation candidates. Pure JS, no LLM. Thresholds live in `lib/gates/*.mjs`.\n\n';
|
||||
out += `Total gates: ${sorted.length}. Budget cap: \`MAX_CODE_CANDIDATES = ${MAX_CODE_CANDIDATES}\`. Gate version: \`${GATE_VERSION}\`.\n\n`;
|
||||
out += '## Gates\n\n';
|
||||
for (const g of sorted) {
|
||||
const m = g.metadata;
|
||||
out += `### \`${m.id}\`\n\n`;
|
||||
out += `- **Threshold**: \`${m.threshold}\`\n`;
|
||||
out += `- **Billing dimension**: ${m.billingDimension}\n`;
|
||||
out += `- **Scope**: ${m.scope}\n`;
|
||||
out += `- **Source citation**: \`${m.sourceCitation}\`\n\n`;
|
||||
out += `${m.description}\n\n`;
|
||||
out += '---\n\n';
|
||||
}
|
||||
return trimTrailingBlankLine(out);
|
||||
}
|
||||
|
||||
function trimTrailingBlankLine(value) {
|
||||
return value.replace(/\n{2,}$/, '\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[check-docs-fresh] FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
+576
@@ -0,0 +1,576 @@
|
||||
#!/usr/bin/env node
|
||||
// Emits signals.json: Vercel CLI capability probe + project config + plan +
|
||||
// usage + codebase stack + metric queries. Status → stderr, JSON → stdout.
|
||||
// Degrades gracefully when capabilities are missing.
|
||||
|
||||
import {
|
||||
checkCliVersion,
|
||||
checkAuth,
|
||||
resolveProjectId,
|
||||
resolveCommandScope,
|
||||
hasObservabilityPlus,
|
||||
checkObservabilityPlusConfiguration,
|
||||
getMetricsSchema,
|
||||
getProjectConfig,
|
||||
getAccountPlan,
|
||||
getContract,
|
||||
getUsage,
|
||||
filterUsageByProject,
|
||||
inferPlan,
|
||||
queryMetric,
|
||||
detectStack,
|
||||
redactSensitiveText,
|
||||
} from '../lib/vercel.mjs';
|
||||
import { classifyFrameworkSupport } from '../lib/framework-support.mjs';
|
||||
import { QUERIES, TIME_WINDOW, normalizerFor } from '../lib/queries.mjs';
|
||||
|
||||
const SCHEMA_VERSION = '1.2';
|
||||
|
||||
const log = (...args) => console.error('[collect-signals]', ...args);
|
||||
|
||||
function parseArgs(argv) {
|
||||
let explicitProjectId = null;
|
||||
let continueWithoutObservability = process.env.VERCEL_OPTIMIZE_CONTINUE_WITHOUT_OBSERVABILITY === '1';
|
||||
let continueUnsupportedFramework = process.env.VERCEL_OPTIMIZE_CONTINUE_UNSUPPORTED_FRAMEWORK === '1';
|
||||
|
||||
for (const arg of argv) {
|
||||
if (arg === '--continue-without-observability') {
|
||||
continueWithoutObservability = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--continue-unsupported-framework') {
|
||||
continueUnsupportedFramework = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--')) {
|
||||
throw new Error(`UNKNOWN_ARG: ${arg}`);
|
||||
}
|
||||
if (!explicitProjectId) {
|
||||
explicitProjectId = arg;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`UNKNOWN_ARG: ${arg}`);
|
||||
}
|
||||
|
||||
return { explicitProjectId, continueWithoutObservability, continueUnsupportedFramework };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { explicitProjectId, continueWithoutObservability, continueUnsupportedFramework } = parseArgs(process.argv.slice(2));
|
||||
|
||||
log('checking Vercel CLI version…');
|
||||
const cli = await checkCliVersion();
|
||||
log(`vercel CLI v${cli.join('.')} OK`);
|
||||
|
||||
log('checking auth…');
|
||||
await checkAuth();
|
||||
log('auth OK');
|
||||
|
||||
log('resolving project id…');
|
||||
const project = await resolveProjectId(explicitProjectId);
|
||||
if (!project) {
|
||||
throw new Error(
|
||||
'NO_PROJECT_ID: pass one as argv, set VERCEL_PROJECT_ID, or run `vercel link` in this directory.'
|
||||
);
|
||||
}
|
||||
log(`project link resolved (source=${project.source}; teamScope=${project.orgId ? 'yes' : 'no'})`);
|
||||
|
||||
if (!project.orgId) {
|
||||
throw new Error('PROJECT_SCOPE_UNRESOLVED: the project was resolved without an owner account. Ask the user which Vercel team or personal scope owns the project, then rerun from a linked app directory or set VERCEL_PROJECT_ID with VERCEL_ORG_ID for that scope.');
|
||||
}
|
||||
|
||||
log('checking framework support…');
|
||||
const stack = await detectStack();
|
||||
const frameworkSupport = classifyFrameworkSupport(stack);
|
||||
log(`framework=${stack.framework}@${stack.frameworkVersion ?? '?'} support=${frameworkSupport.status}`);
|
||||
|
||||
if (!frameworkSupport.ok && !continueUnsupportedFramework) {
|
||||
writeOutput({
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
collectedAt: new Date().toISOString(),
|
||||
timeWindow: TIME_WINDOW,
|
||||
projectId: project.projectId,
|
||||
orgId: project.orgId,
|
||||
projectIdSource: project.source,
|
||||
commandScope: null,
|
||||
frameworkSupport,
|
||||
frameworkSupportBlocker: frameworkSupport.blocker,
|
||||
frameworkSupportDetail: frameworkSupport.detail,
|
||||
observabilityPlus: null,
|
||||
observabilityPlusPreflight: null,
|
||||
observabilityPlusUsable: null,
|
||||
observabilityPlusBlocker: null,
|
||||
observabilityPlusBlockerDetail: null,
|
||||
plan: {
|
||||
plan: 'uncertain',
|
||||
reason: 'not collected before unsupported-framework confirmation',
|
||||
},
|
||||
project: null,
|
||||
contract: null,
|
||||
usage: null,
|
||||
usageScope: null,
|
||||
usageTeamTotal: null,
|
||||
usageError: 'NOT_COLLECTED_UNSUPPORTED_FRAMEWORK',
|
||||
stack,
|
||||
metrics: {},
|
||||
metricsSchema: null,
|
||||
}, { usable: true, blocker: null, detail: 'Observability Plus was not checked.' }, frameworkSupport);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!frameworkSupport.ok && continueUnsupportedFramework) {
|
||||
log('continuing after unsupported framework blocker because --continue-unsupported-framework was set');
|
||||
}
|
||||
|
||||
log('resolving Vercel CLI command scope…');
|
||||
const commandScope = await resolveCommandScope(project);
|
||||
if (!commandScope.ok) {
|
||||
throw new Error(`SCOPE_UNRESOLVED: ${commandScope.detail} Run \`vercel switch <team>\` or re-link with \`vercel link --yes --project <project-name-or-id> --team <team-slug>\`.`);
|
||||
}
|
||||
const scope = commandScope.cliScope || undefined;
|
||||
log(`command scope resolved (source=${commandScope.source}; scoped=${scope ? 'yes' : 'no'})`);
|
||||
|
||||
log('validating linked project belongs to the resolved scope…');
|
||||
const projectCfg = await getProjectConfig(project.projectId, project.orgId);
|
||||
const projectScope = validateProjectScope(projectCfg, project);
|
||||
if (!projectScope.ok) {
|
||||
throw new Error(`PROJECT_SCOPE_MISMATCH: ${projectScope.detail} Ask the user to confirm the exact Vercel project and team/personal scope, then rerun after \`vercel link --yes --project <project-name-or-id> --team <team-slug>\` or after setting both VERCEL_PROJECT_ID and VERCEL_ORG_ID for the intended scope.`);
|
||||
}
|
||||
log(`project scope verified (source=${projectScope.source})`);
|
||||
|
||||
log('checking Observability Plus configuration…');
|
||||
const observabilityPlusConfig = await checkObservabilityPlusConfiguration({
|
||||
orgId: project.orgId,
|
||||
projectId: project.projectId,
|
||||
});
|
||||
log(`observabilityPlusPreflight=${observabilityPlusConfig.access === true ? 'enabled' : observabilityPlusConfig.blocker ?? 'unknown'} (${observabilityPlusConfig.source})`);
|
||||
|
||||
let oplus = observabilityPlusConfig.access === true;
|
||||
if (observabilityPlusConfig.access == null) {
|
||||
log('Observability Plus configuration preflight inconclusive; falling back to metrics schema probe…');
|
||||
oplus = await hasObservabilityPlus(scope);
|
||||
}
|
||||
log(`observabilityPlus=${oplus}`);
|
||||
|
||||
const schema = oplus ? await getMetricsSchema(scope) : null;
|
||||
if (oplus && schema) {
|
||||
const count = Array.isArray(schema) ? schema.length : (schema.metrics?.length ?? 0);
|
||||
log(`metric catalog: ${count} metrics available`);
|
||||
}
|
||||
|
||||
// Check one cheap metric before pulling slower project context. If this fails,
|
||||
// the orchestrator can ask the user immediately instead of waiting on billing.
|
||||
let metrics = {};
|
||||
let metricsCanaryOk = false;
|
||||
if (oplus) {
|
||||
log(`checking Observability Plus metrics access (window=${TIME_WINDOW})…`);
|
||||
const t0 = Date.now();
|
||||
const canary = await queryMetric('vercel.request.count', {
|
||||
aggregation: 'sum',
|
||||
since: TIME_WINDOW,
|
||||
limit: 1,
|
||||
scope,
|
||||
});
|
||||
metricsCanaryOk = !!canary?.ok;
|
||||
if (!metricsCanaryOk) {
|
||||
metrics = {
|
||||
observabilityPlusCanary: {
|
||||
...canary,
|
||||
metricId: 'vercel.request.count',
|
||||
aggregation: 'sum',
|
||||
},
|
||||
};
|
||||
log(`metrics access check failed: ${canary?.code ?? 'unknown'} — skipping full metrics fan-out`);
|
||||
} else {
|
||||
log(`metrics access check passed in ${Date.now() - t0}ms`);
|
||||
}
|
||||
} else {
|
||||
log('skipping metric queries (Observability Plus preflight did not confirm access)');
|
||||
}
|
||||
|
||||
let oplusDiag = observabilityPlusConfig.access === false
|
||||
? {
|
||||
usable: false,
|
||||
blocker: observabilityPlusConfig.blocker,
|
||||
detail: observabilityPlusConfig.detail,
|
||||
}
|
||||
: (metricsCanaryOk
|
||||
? { usable: true, blocker: null, detail: 'Observability Plus metrics access check passed.' }
|
||||
: diagnoseObservabilityPlus(metrics, oplus));
|
||||
|
||||
if (!oplusDiag.usable && !continueWithoutObservability) {
|
||||
writeOutput({
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
collectedAt: new Date().toISOString(),
|
||||
timeWindow: TIME_WINDOW,
|
||||
projectId: project.projectId,
|
||||
orgId: project.orgId,
|
||||
projectIdSource: project.source,
|
||||
commandScope,
|
||||
observabilityPlus: oplus,
|
||||
observabilityPlusPreflight: observabilityPlusConfig,
|
||||
observabilityPlusUsable: oplusDiag.usable,
|
||||
observabilityPlusBlocker: oplusDiag.blocker,
|
||||
observabilityPlusBlockerDetail: oplusDiag.detail,
|
||||
frameworkSupport,
|
||||
frameworkSupportBlocker: frameworkSupport.blocker,
|
||||
frameworkSupportDetail: frameworkSupport.detail,
|
||||
plan: {
|
||||
plan: 'uncertain',
|
||||
reason: 'not collected before Observability Plus blocker confirmation',
|
||||
},
|
||||
project: projectCfg,
|
||||
contract: null,
|
||||
usage: null,
|
||||
usageScope: null,
|
||||
usageTeamTotal: null,
|
||||
usageError: 'NOT_COLLECTED_OBSERVABILITY_BLOCKED',
|
||||
stack: null,
|
||||
metrics,
|
||||
metricsSchema: schema,
|
||||
}, oplusDiag);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!oplusDiag.usable && continueWithoutObservability) {
|
||||
log('continuing after Observability Plus blocker because --continue-without-observability was set');
|
||||
}
|
||||
|
||||
log('pulling account plan + contract + usage in parallel…');
|
||||
const [accountPlan, contract, usageResult] = await Promise.all([
|
||||
getAccountPlan(project.orgId || scope),
|
||||
getContract(scope),
|
||||
getUsage({ days: 14, scope }),
|
||||
]);
|
||||
|
||||
let usage = null;
|
||||
let usageContextMismatch = false;
|
||||
let usageTotalCost = null;
|
||||
let usageScope = 'team';
|
||||
let usageTeamTotal = null;
|
||||
if (usageResult?.ok) {
|
||||
usage = usageResult.data;
|
||||
const contractContext = contract?.context;
|
||||
if (usage?.context && contractContext && usage.context !== contractContext) {
|
||||
usageContextMismatch = true;
|
||||
log(`usage: WARNING context mismatch — returned context=${usage.context} but project team=${contractContext}; treating usage as unavailable for this project`);
|
||||
usage = null;
|
||||
} else {
|
||||
// Capture team total pre-filter so the report can label "this project vs team-wide" honestly.
|
||||
usageTeamTotal = sumUsageCosts(usage);
|
||||
const filterResult = filterUsageByProject(usage, project.projectId, projectCfg?.name);
|
||||
if (filterResult.matched) {
|
||||
usage = filterResult.filtered;
|
||||
usageScope = 'project';
|
||||
usageTotalCost = sumUsageCosts(usage);
|
||||
log(`usage: filtered to project — ~$${usageTotalCost.toFixed(2)} (team-wide ~$${usageTeamTotal.toFixed(2)}; unattributed ~$${filterResult.unattributedTotal.toFixed(2)})`);
|
||||
} else {
|
||||
usageTotalCost = usageTeamTotal;
|
||||
log(`usage: ~$${usageTotalCost.toFixed(2)} billed across services (team-wide — no per-project usage rows matched the linked project; report will label this team-wide)`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log(`usage: unavailable (${usageResult?.code ?? 'unknown'}) — degrading to scanner+metrics-only mode`);
|
||||
}
|
||||
|
||||
const planInfo = inferPlan(contract, { accountPlan, usageTotalCost });
|
||||
log(`plan=${planInfo.plan} (${planInfo.reason})`);
|
||||
|
||||
if (projectCfg?.error) {
|
||||
log(`project config: failed (${projectCfg.error}) — gates that need it will skip`);
|
||||
}
|
||||
|
||||
log(`stack: ${stack.framework}@${stack.frameworkVersion ?? '?'} ${stack.hasAppRouter ? 'app-router' : ''}${stack.hasPagesRouter ? ' pages-router' : ''}${stack.orm !== 'none' ? ` orm=${stack.orm}` : ''}`);
|
||||
|
||||
// Each query is wrapped; one failure degrades only that metric.
|
||||
if (oplus && metricsCanaryOk) {
|
||||
log(`querying observability metrics (${QUERIES.length} metrics in parallel)…`);
|
||||
const t0 = Date.now();
|
||||
metrics = await collectMetrics(scope);
|
||||
const wallMs = Date.now() - t0;
|
||||
const counts = Object.fromEntries(
|
||||
Object.entries(metrics).map(([k, v]) => {
|
||||
if (!v) return [k, 'null'];
|
||||
if (!v.ok) return [k, `err:${v.code}`];
|
||||
const rows = Array.isArray(v.rows) ? v.rows.length : 0;
|
||||
return [k, `${rows} rows`];
|
||||
})
|
||||
);
|
||||
log(`metrics collected in ${wallMs}ms: ${JSON.stringify(counts)}`);
|
||||
}
|
||||
|
||||
// The `vercel metrics schema` probe alone is NOT a reliable usability signal:
|
||||
// it can return OK while per-route queries fail with payment_required (metrics
|
||||
// unavailable for the team) or FORBIDDEN (auth-scope mismatch). Diagnose AFTER
|
||||
// running queries by counting failure codes so the orchestrator can PAUSE and
|
||||
// surface the choice before falling back to scanner-only mode.
|
||||
oplusDiag = observabilityPlusConfig.access === false
|
||||
? {
|
||||
usable: false,
|
||||
blocker: observabilityPlusConfig.blocker,
|
||||
detail: observabilityPlusConfig.detail,
|
||||
}
|
||||
: diagnoseObservabilityPlus(metrics, oplus);
|
||||
|
||||
|
||||
const output = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
collectedAt: new Date().toISOString(),
|
||||
timeWindow: TIME_WINDOW,
|
||||
projectId: project.projectId,
|
||||
orgId: project.orgId,
|
||||
projectIdSource: project.source,
|
||||
commandScope,
|
||||
observabilityPlus: oplus,
|
||||
observabilityPlusPreflight: observabilityPlusConfig,
|
||||
observabilityPlusUsable: oplusDiag.usable,
|
||||
observabilityPlusBlocker: oplusDiag.blocker,
|
||||
observabilityPlusBlockerDetail: oplusDiag.detail,
|
||||
frameworkSupport,
|
||||
frameworkSupportBlocker: frameworkSupport.blocker,
|
||||
frameworkSupportDetail: frameworkSupport.detail,
|
||||
plan: planInfo,
|
||||
project: projectCfg,
|
||||
contract,
|
||||
usage,
|
||||
usageScope,
|
||||
usageTeamTotal,
|
||||
usageError: usageResult?.ok
|
||||
? (usageContextMismatch ? 'USAGE_CONTEXT_MISMATCH' : null)
|
||||
: (usageResult?.code ?? 'UNKNOWN'),
|
||||
stack,
|
||||
metrics,
|
||||
metricsSchema: schema,
|
||||
};
|
||||
|
||||
writeOutput(output, oplusDiag);
|
||||
}
|
||||
|
||||
function writeOutput(output, oplusDiag, frameworkSupport = output.frameworkSupport) {
|
||||
if (frameworkSupport?.blocker) {
|
||||
log(`⚠ Framework is not supported for metric-backed route-to-file optimization: ${frameworkSupport.detail}`);
|
||||
log(' The orchestrator should PAUSE and ask whether to continue with a limited platform/scanner audit.');
|
||||
}
|
||||
if (!oplusDiag.usable) {
|
||||
log(`⚠ Observability Plus is NOT usable on this project: blocker=${oplusDiag.blocker} (${oplusDiag.detail})`);
|
||||
log(' The orchestrator should PAUSE and follow the blocker-specific remediation before proceeding.');
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
log('done');
|
||||
}
|
||||
|
||||
function validateProjectScope(projectCfg, project) {
|
||||
if (!projectCfg || projectCfg.error) {
|
||||
return {
|
||||
ok: false,
|
||||
source: 'project-api',
|
||||
detail: `The resolved account could not read the resolved project (project API error=${projectCfg?.error ?? 'unknown'}).`,
|
||||
};
|
||||
}
|
||||
|
||||
if (projectCfg.id && String(projectCfg.id) !== String(project.projectId)) {
|
||||
return {
|
||||
ok: false,
|
||||
source: 'project-api',
|
||||
detail: 'The project API returned a different project than the collector resolved from the link or environment.',
|
||||
};
|
||||
}
|
||||
|
||||
const ownerId = firstString(
|
||||
projectCfg.accountId,
|
||||
projectCfg.orgId,
|
||||
projectCfg.ownerId,
|
||||
projectCfg.teamId,
|
||||
projectCfg.team?.id,
|
||||
projectCfg.account?.id,
|
||||
projectCfg.owner?.id,
|
||||
);
|
||||
if (ownerId && project.orgId && String(ownerId) !== String(project.orgId)) {
|
||||
return {
|
||||
ok: false,
|
||||
source: 'project-api',
|
||||
detail: 'The project API returned an owner account that differs from the collector-resolved account.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
source: ownerId ? 'project-api-owner' : 'project-api-readable',
|
||||
};
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
return values.find((value) => typeof value === 'string' && value.trim() !== '') ?? null;
|
||||
}
|
||||
|
||||
async function collectMetrics(scope) {
|
||||
const results = await Promise.all(
|
||||
QUERIES.map(async (entry) => {
|
||||
const r = await queryMetric(entry.metricId, {
|
||||
aggregation: entry.aggregation,
|
||||
groupBy: entry.groupBy,
|
||||
filter: entry.filter,
|
||||
since: TIME_WINDOW,
|
||||
limit: entry.limit,
|
||||
scope,
|
||||
});
|
||||
return [entry, r];
|
||||
})
|
||||
);
|
||||
|
||||
const out = {};
|
||||
for (const [entry, result] of results) {
|
||||
out[entry.id] = enrichEntry(entry, result);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function enrichEntry(entry, result) {
|
||||
if (!result?.ok) {
|
||||
return {
|
||||
...result,
|
||||
metricId: entry.metricId,
|
||||
aggregation: entry.aggregation,
|
||||
groupBy: entry.groupBy,
|
||||
};
|
||||
}
|
||||
const normalize = normalizerFor(entry);
|
||||
const { rows } = normalize(result.data);
|
||||
return {
|
||||
...result,
|
||||
rows,
|
||||
metricId: entry.metricId,
|
||||
aggregation: entry.aggregation,
|
||||
groupBy: entry.groupBy,
|
||||
};
|
||||
}
|
||||
|
||||
// `vercel usage --format json` shape is documented but not stable across CLI
|
||||
// versions; try several roots, return null if none match.
|
||||
function sumUsageCosts(usage) {
|
||||
if (!usage) return null;
|
||||
if (typeof usage.totalCost === 'number') return usage.totalCost;
|
||||
if (typeof usage.totals?.billedCost === 'number') return usage.totals.billedCost;
|
||||
if (Array.isArray(usage.services)) {
|
||||
return usage.services.reduce((s, x) => s + (x.billedCost ?? x.cost ?? 0), 0);
|
||||
}
|
||||
if (Array.isArray(usage.breakdown?.data)) {
|
||||
return usage.breakdown.data.reduce((s, d) => {
|
||||
if (Array.isArray(d.services)) {
|
||||
return s + d.services.reduce((ss, x) => ss + (x.billedCost ?? x.cost ?? 0), 0);
|
||||
}
|
||||
return s + (d.billedCost ?? d.cost ?? 0);
|
||||
}, 0);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns { usable, blocker, detail }. `blocker` enum:
|
||||
// null | 'no_oplus_probe' | 'project_disabled' | 'payment_required' |
|
||||
// 'forbidden' | 'daily_quota_exceeded' | 'project_not_found' |
|
||||
// 'not_linked' | 'all_failed_other' | 'no_traffic'
|
||||
export function diagnoseObservabilityPlus(metrics, oplusProbe) {
|
||||
if (!oplusProbe) {
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'no_oplus_probe',
|
||||
detail: 'vercel metrics schema returned non-OK; the team does not have Observability Plus enabled.',
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.values(metrics);
|
||||
if (entries.length === 0) {
|
||||
return { usable: false, blocker: 'no_oplus_probe', detail: 'No metrics were attempted.' };
|
||||
}
|
||||
|
||||
const failures = entries.filter((m) => m && m.ok === false);
|
||||
const successes = entries.filter((m) => m && m.ok !== false);
|
||||
|
||||
if (successes.length === 0) {
|
||||
const codeCounts = new Map();
|
||||
for (const f of failures) {
|
||||
const code = String(f.code ?? 'unknown').toLowerCase();
|
||||
codeCounts.set(code, (codeCounts.get(code) ?? 0) + 1);
|
||||
}
|
||||
const top = [...codeCounts.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const topCode = top?.[0] ?? 'unknown';
|
||||
if (/daily_quota_exceeded/.test(topCode)) {
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'daily_quota_exceeded',
|
||||
detail: `${top[1]}/${entries.length} metric queries hit the daily Observability query limit. Retry after the next UTC midnight reset.`,
|
||||
};
|
||||
}
|
||||
if (/payment_required/.test(topCode)) {
|
||||
const text = failures
|
||||
.map((f) => `${f.message ?? ''}\n${f.stderr ?? ''}`)
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
if (
|
||||
/subscription to observability plus[\s\S]{0,160}required/.test(text) ||
|
||||
/observability plus[\s\S]{0,160}not enabled/.test(text)
|
||||
) {
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'no_oplus_probe',
|
||||
detail: `${top[1]}/${entries.length} metric queries need route-level Observability Plus data. Enable Observability Plus, then re-run the metric-backed audit.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'payment_required',
|
||||
detail: `${top[1]}/${entries.length} metric queries returned payment_required. Route-level metrics were recognized for this team, but these queries are not usable. Check the team's Observability Plus subscription or event quota.`,
|
||||
};
|
||||
}
|
||||
if (/forbidden|not_authorized|403/.test(topCode)) {
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'forbidden',
|
||||
detail: `${top[1]}/${entries.length} metric queries returned FORBIDDEN. Auth-scope mismatch — likely logged in to the wrong team (run \`vercel switch\`).`,
|
||||
};
|
||||
}
|
||||
if (/project_not_found/.test(topCode)) {
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'project_not_found',
|
||||
detail: `Project ID not visible to the auth'd team. Run \`vercel switch\` or verify the project ID.`,
|
||||
};
|
||||
}
|
||||
if (/not_linked/.test(topCode)) {
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'not_linked',
|
||||
detail: `${top[1]}/${entries.length} metric queries returned NOT_LINKED. Link the app directory first: \`vercel link --yes --project <project-name-or-id> --cwd <project-dir>\`; add \`--team <team-id-or-slug>\` when the team is known.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
usable: false,
|
||||
blocker: 'all_failed_other',
|
||||
detail: `Every metric query failed; top error code was \`${topCode}\` (${top?.[1]}/${entries.length}).`,
|
||||
};
|
||||
}
|
||||
|
||||
// Some queries succeeded; zero rows across the board = "no traffic in window",
|
||||
// NOT an Observability Plus billing issue.
|
||||
const totalRows = successes.reduce((s, m) => s + (Array.isArray(m.rows) ? m.rows.length : 0), 0);
|
||||
if (totalRows === 0) {
|
||||
return {
|
||||
usable: true,
|
||||
blocker: 'no_traffic',
|
||||
detail: 'Observability Plus queries succeeded but every metric returned 0 rows. Either the project has no traffic in the 14-day window, or Observability Plus retention is limited (free tier = 1 day on Pro).',
|
||||
};
|
||||
}
|
||||
|
||||
return { usable: true, blocker: null, detail: 'Observability Plus is usable; queries returned data.' };
|
||||
}
|
||||
|
||||
// Run main() only as a CLI; the test suite imports diagnoseObservabilityPlus directly.
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { realpathSync } from 'node:fs';
|
||||
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
main().catch((err) => {
|
||||
console.error('[collect-signals] FAILED:', redactSensitiveText(err.message));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env node
|
||||
// Collect raw sub-agent outputs into the recommendations.json array consumed by
|
||||
// verify-and-regen. Sub-agent hosts often wrap JSON in prose or markdown fences,
|
||||
// so extraction is permissive while candidateRef coverage stays strict.
|
||||
|
||||
import { readFile, readdir, stat, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve, basename } from 'node:path';
|
||||
|
||||
const log = (...a) => console.error('[collect-sub-agent-outputs]', ...a);
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (args.inputs.length === 0 && !args.manifestPath) {
|
||||
console.error('usage: node scripts/collect-sub-agent-outputs.mjs [--manifest briefs/manifest.json] <output-file-or-dir...> [--out recommendations.json] [--strict]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const manifest = args.manifestPath
|
||||
? JSON.parse(await readFile(args.manifestPath, 'utf-8'))
|
||||
: null;
|
||||
const expected = manifest ? readExpectedBriefs(manifest) : [];
|
||||
const preResolvedRecords = manifest ? readPreResolvedRecords(manifest) : [];
|
||||
const files = args.inputs.length > 0 ? await collectInputFiles(args.inputs) : [];
|
||||
const collected = [];
|
||||
const summary = {
|
||||
files: files.length,
|
||||
kept: 0,
|
||||
abstained: 0,
|
||||
parseFailed: 0,
|
||||
nonObject: 0,
|
||||
missingCandidateRef: 0,
|
||||
};
|
||||
const errors = [];
|
||||
|
||||
for (const file of files) {
|
||||
const raw = await readFile(file, 'utf-8');
|
||||
const extracted = extractJsonValue(raw);
|
||||
if (!extracted.ok) {
|
||||
summary.parseFailed++;
|
||||
const msg = `${file}: ${extracted.reason}`;
|
||||
if (args.strict) errors.push(msg);
|
||||
else log(`warn: ${msg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const records = normalizeOutput(extracted.value);
|
||||
if (records.length === 0) {
|
||||
summary.nonObject++;
|
||||
const msg = `${file}: JSON did not contain a recommendation or abstention object`;
|
||||
if (args.strict) errors.push(msg);
|
||||
else log(`warn: ${msg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const record of records) {
|
||||
const candidateRef = record.candidateRef ?? inferCandidateRefFromFile(file, expected, records.length);
|
||||
if (!candidateRef) {
|
||||
summary.missingCandidateRef++;
|
||||
errors.push(`${file}: output is missing candidateRef`);
|
||||
continue;
|
||||
}
|
||||
collected.push({
|
||||
sourcePath: file,
|
||||
record: record.candidateRef ? record : { ...record, candidateRef },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ordered = collected;
|
||||
if (expected.length > 0) {
|
||||
const byRef = new Map();
|
||||
for (const item of collected) {
|
||||
const ref = item.record.candidateRef;
|
||||
if (!expected.some((b) => b.candidateRef === ref)) {
|
||||
errors.push(`${item.sourcePath}: unknown candidateRef ${ref}`);
|
||||
continue;
|
||||
}
|
||||
if (byRef.has(ref)) {
|
||||
errors.push(`${item.sourcePath}: duplicate output for candidateRef ${ref}`);
|
||||
continue;
|
||||
}
|
||||
byRef.set(ref, item);
|
||||
}
|
||||
const missing = expected.filter((b) => !byRef.has(b.candidateRef));
|
||||
for (const b of missing) errors.push(`missing output for candidateRef ${b.candidateRef}`);
|
||||
ordered = expected.map((b) => byRef.get(b.candidateRef)).filter(Boolean);
|
||||
} else {
|
||||
ordered = collected.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
|
||||
}
|
||||
|
||||
const records = [...preResolvedRecords, ...ordered.map((item) => item.record)];
|
||||
summary.kept = records.filter((r) => r?.abstain !== true).length;
|
||||
summary.abstained = records.filter((r) => r?.abstain === true).length;
|
||||
|
||||
if (errors.length > 0) {
|
||||
for (const e of errors) log(`error: ${e}`);
|
||||
process.exit(2);
|
||||
}
|
||||
if (records.length === 0) {
|
||||
log('error: no recommendation or abstention records collected');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(records, null, 2) + '\n';
|
||||
if (args.outPath) {
|
||||
await mkdir(dirname(args.outPath), { recursive: true });
|
||||
await writeFile(args.outPath, serialized, 'utf-8');
|
||||
log(`wrote ${serialized.length}B → ${args.outPath}`);
|
||||
} else {
|
||||
process.stdout.write(serialized);
|
||||
}
|
||||
log(`done: ${summary.files} files, ${summary.kept} recommendation draft(s), ${summary.abstained} found no supported change, ${summary.parseFailed} parse failed, ${summary.nonObject} invalid output(s)`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { inputs: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--manifest') out.manifestPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--manifest=')) out.manifestPath = resolve(a.slice('--manifest='.length));
|
||||
else if (a === '--out') out.outPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--out=')) out.outPath = resolve(a.slice('--out='.length));
|
||||
else if (a === '--strict') out.strict = true;
|
||||
else out.inputs.push(resolve(a));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function collectInputFiles(paths) {
|
||||
const out = [];
|
||||
for (const p of paths) {
|
||||
const s = await stat(p);
|
||||
if (s.isDirectory()) out.push(...await walkDir(p));
|
||||
else if (s.isFile()) out.push(p);
|
||||
}
|
||||
return out.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
async function walkDir(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const out = [];
|
||||
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (e.name.startsWith('.')) continue;
|
||||
const p = resolve(dir, e.name);
|
||||
if (e.isDirectory()) out.push(...await walkDir(p));
|
||||
else if (e.isFile()) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function readExpectedBriefs(manifest) {
|
||||
if (!manifest || typeof manifest !== 'object' || !Array.isArray(manifest.briefs)) {
|
||||
throw new TypeError('manifest must contain a briefs array');
|
||||
}
|
||||
return manifest.briefs.map((b, i) => {
|
||||
if (!b?.candidateRef) throw new TypeError(`manifest.briefs[${i}].candidateRef is required`);
|
||||
return {
|
||||
group: b.group ?? null,
|
||||
index: b.index ?? i,
|
||||
candidateRef: b.candidateRef,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function readPreResolvedRecords(manifest) {
|
||||
if (!manifest || !Array.isArray(manifest.preResolvedRecords)) return [];
|
||||
return manifest.preResolvedRecords.map((r, i) => {
|
||||
if (!isRecordObject(r)) {
|
||||
throw new TypeError(`manifest.preResolvedRecords[${i}] must be a recommendation or no-recommendation record`);
|
||||
}
|
||||
if (!r.candidateRef) {
|
||||
throw new TypeError(`manifest.preResolvedRecords[${i}].candidateRef is required`);
|
||||
}
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
function extractJsonValue(raw) {
|
||||
for (const block of extractFenceBlocks(raw)) {
|
||||
const parsed = tryParseJson(block);
|
||||
if (parsed.ok) return parsed;
|
||||
}
|
||||
const full = tryParseJson(raw);
|
||||
if (full.ok) return full;
|
||||
for (const span of findBalancedJsonSpans(raw)) {
|
||||
const parsed = tryParseJson(span);
|
||||
if (parsed.ok) return parsed;
|
||||
}
|
||||
return { ok: false, reason: 'no valid JSON object or array found' };
|
||||
}
|
||||
|
||||
function extractFenceBlocks(raw) {
|
||||
const out = [];
|
||||
const re = /```(?:json|JSON)?\s*\n([\s\S]*?)```/g;
|
||||
let m;
|
||||
while ((m = re.exec(raw)) !== null) out.push(m[1].trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
function tryParseJson(raw) {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(raw.trim()) };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function findBalancedJsonSpans(raw) {
|
||||
const spans = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const ch = raw[i];
|
||||
if (ch !== '{' && ch !== '[') continue;
|
||||
const closeFor = ch === '{' ? '}' : ']';
|
||||
const stack = [closeFor];
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let j = i + 1; j < raw.length; j++) {
|
||||
const c = raw[j];
|
||||
if (inString) {
|
||||
if (escape) escape = false;
|
||||
else if (c === '\\') escape = true;
|
||||
else if (c === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (c === '{') stack.push('}');
|
||||
else if (c === '[') stack.push(']');
|
||||
else if (c === '}' || c === ']') {
|
||||
if (stack.at(-1) !== c) break;
|
||||
stack.pop();
|
||||
if (stack.length === 0) {
|
||||
spans.push(raw.slice(i, j + 1));
|
||||
i = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
function normalizeOutput(value) {
|
||||
const unwrapped = unwrapEnvelope(value);
|
||||
if (Array.isArray(unwrapped)) return unwrapped.filter(isRecordObject);
|
||||
if (isRecordObject(unwrapped)) return [unwrapped];
|
||||
if (unwrapped && typeof unwrapped === 'object') {
|
||||
if (isRecordObject(unwrapped.recommendation)) return [unwrapped.recommendation];
|
||||
if (Array.isArray(unwrapped.recommendations)) return unwrapped.recommendations.filter(isRecordObject);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function unwrapEnvelope(value) {
|
||||
let current = value;
|
||||
for (let depth = 0; depth < 2; depth++) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) return current;
|
||||
if (Array.isArray(current.recommendations) || current.recommendation) return current;
|
||||
const keys = Object.keys(current);
|
||||
const envelopeKey = ['data', 'result', 'insights'].find((k) => keys.length === 1 && k in current);
|
||||
if (!envelopeKey) return current;
|
||||
current = current[envelopeKey];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isRecordObject(value) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
if (value.abstain === true) return true;
|
||||
return ['what', 'why', 'fix', 'bucket', 'affectedFiles', 'citations'].some((k) => k in value);
|
||||
}
|
||||
|
||||
function inferCandidateRefFromFile(file, expected, recordCount) {
|
||||
if (recordCount !== 1 || expected.length === 0) return null;
|
||||
if (expected.length === 1) return expected[0].candidateRef;
|
||||
const name = basename(file);
|
||||
const matches = expected.filter((b) => {
|
||||
if (!b.group && b.index == null) return false;
|
||||
const group = escapeRegExp(String(b.group ?? ''));
|
||||
const index = escapeRegExp(String(b.index));
|
||||
return new RegExp(`(?:^|[^A-Za-z0-9])${group}[-_.]?${index}(?:[^A-Za-z0-9]|$)`).test(name);
|
||||
});
|
||||
return matches.length === 1 ? matches[0].candidateRef : null;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[collect-sub-agent-outputs] FAILED:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env node
|
||||
// Runs AFTER gate-investigations.mjs and BEFORE any sub-agent reads source.
|
||||
// Attaches per-candidate evidence.deepDive to gate.toLaunch + gate.platform.
|
||||
// Byte-stable apart from totalWallMs; each CLI query is isolated.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { queryMetric, readProjectJson, resolveCommandScope } from '../lib/vercel.mjs';
|
||||
import { specsForCandidate, mergeIntoEvidence, SCANNER_KINDS, TIME_WINDOW } from '../lib/deep-dive.mjs';
|
||||
|
||||
const SCHEMA_VERSION = '1.0';
|
||||
const log = (...a) => console.error('[deep-dive]', ...a);
|
||||
|
||||
async function main() {
|
||||
// --cwd is load-bearing: the Vercel CLI resolves project/team from cwd's
|
||||
// .vercel/project.json. Outside the project, metric queries silently hit the
|
||||
// wrong team and look like "no traffic". We hard-fail on mismatch below.
|
||||
const positional = [];
|
||||
let explicitCwd = null;
|
||||
for (let i = 2; i < process.argv.length; i++) {
|
||||
const a = process.argv[i];
|
||||
if (a === '--cwd' && i + 1 < process.argv.length) {
|
||||
explicitCwd = process.argv[++i];
|
||||
} else if (a.startsWith('--cwd=')) {
|
||||
explicitCwd = a.slice('--cwd='.length);
|
||||
} else {
|
||||
positional.push(a);
|
||||
}
|
||||
}
|
||||
const mergedPath = positional[0];
|
||||
const gatePath = positional[1];
|
||||
if (!mergedPath || !gatePath) {
|
||||
console.error('usage: node scripts/deep-dive.mjs <merged.json> <gate.json> [--cwd <project-dir>]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [merged, gate] = await Promise.all([
|
||||
readFile(mergedPath, 'utf-8').then(JSON.parse),
|
||||
readFile(gatePath, 'utf-8').then(JSON.parse),
|
||||
]);
|
||||
|
||||
if (explicitCwd) {
|
||||
process.chdir(explicitCwd);
|
||||
log(`cwd: ${process.cwd()} (via --cwd)`);
|
||||
}
|
||||
|
||||
const link = await readProjectJson(process.cwd());
|
||||
if (!link) {
|
||||
console.error(`[deep-dive] FATAL: cwd ${process.cwd()} has no .vercel/project.json or .vercel/repo.json.`);
|
||||
console.error(' Re-run with --cwd <project-dir> pointing at the linked project, or cd into it first.');
|
||||
console.error(' (The Vercel CLI resolves team/project from cwd; without a .vercel/ linkage every query returns empty rows for the wrong team.)');
|
||||
process.exit(2);
|
||||
}
|
||||
if (merged.projectId && link.projectId !== merged.projectId) {
|
||||
console.error('[deep-dive] FATAL: cwd .vercel/ links a different project than merged.json.');
|
||||
console.error(' Re-run with --cwd <dir-linked-to-the-collected-project>.');
|
||||
process.exit(2);
|
||||
}
|
||||
if (merged.orgId && link.orgId && link.orgId !== merged.orgId) {
|
||||
console.error('[deep-dive] FATAL: cwd .vercel/ links the project to a different Vercel scope than signals.json.');
|
||||
console.error(' Re-run with --cwd <dir-linked-to-the-collected-project>, or rerun collect-signals.mjs from the intended app directory.');
|
||||
process.exit(2);
|
||||
}
|
||||
log(`cwd link OK (source ${link.source})`);
|
||||
|
||||
const commandScope = await resolveDeepDiveCommandScope(merged, link);
|
||||
if (!commandScope.ok) {
|
||||
console.error(`[deep-dive] FATAL: could not resolve a CLI-safe Vercel scope (${commandScope.detail ?? commandScope.error ?? 'unknown'}).`);
|
||||
console.error(' Re-run collect-signals.mjs with the current skill, run `vercel switch <team>`, or re-link with `vercel link --yes --project <project> --team <team-slug>`.');
|
||||
process.exit(2);
|
||||
}
|
||||
if (typeof commandScope.cliScope === 'string' && /^(team|usr)_/.test(commandScope.cliScope)) {
|
||||
console.error('[deep-dive] FATAL: commandScope.cliScope is a raw account ID, not a CLI-safe scope.');
|
||||
console.error(' Re-run collect-signals.mjs with the current skill so deep-dive queries use the same team as the broad pass.');
|
||||
process.exit(2);
|
||||
}
|
||||
const commandAccountId = commandScope.teamId ?? commandScope.userId ?? null;
|
||||
if (commandAccountId && link.orgId && link.orgId !== commandAccountId) {
|
||||
console.error('[deep-dive] FATAL: cwd .vercel/ links the project to a different Vercel scope than commandScope.');
|
||||
console.error(' Re-run with --cwd <dir-linked-to-the-collected-project>, or rerun collect-signals.mjs from the intended app directory.');
|
||||
process.exit(2);
|
||||
}
|
||||
const scope = commandScope.cliScope || undefined;
|
||||
log(`command scope resolved (source=${commandScope.source}; scoped=${scope ? 'yes' : 'no'})`);
|
||||
|
||||
const toLaunch = Array.isArray(gate.toLaunch) ? gate.toLaunch : [];
|
||||
const platform = Array.isArray(gate.platform) ? gate.platform : [];
|
||||
|
||||
log(`enriching ${toLaunch.length} toLaunch + ${platform.length} platform candidate(s) (window=${TIME_WINDOW})`);
|
||||
|
||||
const t0 = Date.now();
|
||||
const errors = [];
|
||||
|
||||
// Flatten {candidate, spec}, fire all CLI calls in one Promise.all, re-group.
|
||||
// Avoids per-candidate sequentiality.
|
||||
const allCandidates = [...toLaunch.map((c, i) => ({ c, group: 'toLaunch', i })),
|
||||
...platform.map((c, i) => ({ c, group: 'platform', i }))];
|
||||
|
||||
const flatJobs = [];
|
||||
const skipNotes = new Map();
|
||||
|
||||
for (const entry of allCandidates) {
|
||||
const specs = specsForCandidate(entry.c);
|
||||
if (specs.length === 0) {
|
||||
if (SCANNER_KINDS.has(entry.c.kind)) {
|
||||
skipNotes.set(`${entry.group}:${entry.i}`, 'scanner-driven (no deep-dive needed)');
|
||||
} else if (entry.c.kind === 'platform_fluid_compute') {
|
||||
skipNotes.set(`${entry.group}:${entry.i}`, 'reused from broad pass (fnStartTypeByRoute)');
|
||||
} else {
|
||||
skipNotes.set(`${entry.group}:${entry.i}`, `no deep-dive spec for kind=${entry.c.kind}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const spec of specs) {
|
||||
flatJobs.push({ entry, spec });
|
||||
}
|
||||
}
|
||||
|
||||
// Cut CLI calls two ways: (1) extract per-route slices already collected in
|
||||
// the broad pass; (2) dedupe identical queries across candidates (same route
|
||||
// can fire multiple gates wanting the same metric).
|
||||
let extractedFromBroadPass = 0;
|
||||
let dedupedQueryHits = 0;
|
||||
const broadPassResults = [];
|
||||
const remainingJobs = [];
|
||||
for (const job of flatJobs) {
|
||||
const extracted = tryExtractFromBroadPass(job.spec, merged);
|
||||
if (extracted) {
|
||||
broadPassResults.push({ entry: job.entry, spec: job.spec, ok: true, ...extracted });
|
||||
extractedFromBroadPass++;
|
||||
} else {
|
||||
remainingJobs.push(job);
|
||||
}
|
||||
}
|
||||
|
||||
// One CLI call per unique dedup key; jobs sharing a key share the result.
|
||||
const queryGroups = new Map();
|
||||
for (const job of remainingJobs) {
|
||||
const key = queryKey(job.spec, scope);
|
||||
if (!queryGroups.has(key)) {
|
||||
queryGroups.set(key, { spec: job.spec, jobs: [] });
|
||||
}
|
||||
queryGroups.get(key).jobs.push(job);
|
||||
}
|
||||
dedupedQueryHits = remainingJobs.length - queryGroups.size;
|
||||
|
||||
const totalCliQueries = queryGroups.size;
|
||||
log(`${flatJobs.length} specs total: ${extractedFromBroadPass} extracted from broad-pass, ${dedupedQueryHits} deduped, ${totalCliQueries} CLI queries to run`);
|
||||
|
||||
const groupResults = await Promise.all([...queryGroups.values()].map(async ({ spec, jobs }) => {
|
||||
const r = await queryMetric(spec.metricId, {
|
||||
aggregation: spec.aggregation,
|
||||
groupBy: spec.groupBy,
|
||||
filter: spec.filter,
|
||||
since: spec.since,
|
||||
limit: spec.limit,
|
||||
scope,
|
||||
});
|
||||
return { spec, jobs, response: r };
|
||||
}));
|
||||
|
||||
const cliResults = [];
|
||||
for (const { spec, jobs, response: r } of groupResults) {
|
||||
if (!r.ok) {
|
||||
for (const job of jobs) {
|
||||
errors.push({
|
||||
candidateGroup: job.entry.group,
|
||||
candidateIndex: job.entry.i,
|
||||
kind: job.entry.c.kind,
|
||||
route: job.entry.c.route ?? job.entry.c.hostname ?? null,
|
||||
specId: spec.id,
|
||||
code: r.code,
|
||||
});
|
||||
cliResults.push({ entry: job.entry, spec, ok: false, error: r.code });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const norm = normalizeResponse(r.data, spec);
|
||||
for (const job of jobs) {
|
||||
cliResults.push({ entry: job.entry, spec, ok: true, ...norm });
|
||||
}
|
||||
}
|
||||
const results = [...broadPassResults, ...cliResults];
|
||||
|
||||
const wallMs = Date.now() - t0;
|
||||
log(`done in ${wallMs}ms (${totalCliQueries} CLI queries, ${extractedFromBroadPass} extracted from broad-pass, ${dedupedQueryHits} deduped, ${errors.length} errors)`);
|
||||
|
||||
const byCandidate = new Map();
|
||||
for (const res of results) {
|
||||
const k = `${res.entry.group}:${res.entry.i}`;
|
||||
if (!byCandidate.has(k)) byCandidate.set(k, []);
|
||||
byCandidate.get(k).push(res);
|
||||
}
|
||||
|
||||
function enrich(c, group, i) {
|
||||
const k = `${group}:${i}`;
|
||||
const note = skipNotes.get(k);
|
||||
if (note) {
|
||||
return {
|
||||
...c,
|
||||
evidence: {
|
||||
...(c.evidence ?? {}),
|
||||
deepDive: { note },
|
||||
},
|
||||
};
|
||||
}
|
||||
const list = byCandidate.get(k) ?? [];
|
||||
const merged = mergeIntoEvidence(list);
|
||||
return {
|
||||
...c,
|
||||
evidence: {
|
||||
...(c.evidence ?? {}),
|
||||
deepDive: merged,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const enrichedToLaunch = toLaunch.map((c, i) => enrich(c, 'toLaunch', i));
|
||||
const enrichedPlatform = platform.map((c, i) => enrich(c, 'platform', i));
|
||||
|
||||
const out = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
appliedAt: new Date().toISOString(),
|
||||
candidatesEnriched: toLaunch.length + platform.length,
|
||||
specsTotal: flatJobs.length,
|
||||
queriesRun: totalCliQueries,
|
||||
extractedFromBroadPass,
|
||||
dedupedQueryHits,
|
||||
totalWallMs: wallMs,
|
||||
errors,
|
||||
toLaunch: enrichedToLaunch,
|
||||
platform: enrichedPlatform,
|
||||
};
|
||||
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
||||
}
|
||||
|
||||
async function resolveDeepDiveCommandScope(merged, link) {
|
||||
const linkedOrgId = merged.orgId ?? link.orgId ?? null;
|
||||
if (merged.commandScope?.ok && (merged.commandScope.cliScope || !linkedOrgId)) {
|
||||
return merged.commandScope;
|
||||
}
|
||||
if (merged.commandScope && merged.commandScope.ok === false) return merged.commandScope;
|
||||
|
||||
return await resolveCommandScope({
|
||||
projectId: merged.projectId ?? link.projectId ?? null,
|
||||
orgId: merged.orgId ?? link.orgId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Reduce CLI response to {value} or {rows:[{value,...dims}]}. The per-metric
|
||||
// underscore field (e.g. vercel_function_invocation_count_sum) gets renamed
|
||||
// to `value` for compactness.
|
||||
function normalizeResponse(data, spec) {
|
||||
if (!data || !Array.isArray(data.summary)) return { value: null };
|
||||
const field = `${spec.metricId.replace(/\./g, '_')}_${spec.aggregation}`;
|
||||
if (spec.groupBy.length === 0) {
|
||||
const first = data.summary[0];
|
||||
if (!first) return { value: null };
|
||||
const v = first[field];
|
||||
return { value: typeof v === 'number' ? round4(v) : null };
|
||||
}
|
||||
const rows = data.summary.map((row) => {
|
||||
const out = { value: typeof row[field] === 'number' ? round4(row[field]) : null };
|
||||
for (const dim of spec.groupBy) {
|
||||
if (row[dim] !== undefined) out[dim] = row[dim];
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return { rows };
|
||||
}
|
||||
|
||||
function round4(n) {
|
||||
if (!Number.isFinite(n)) return n;
|
||||
return Math.round(n * 10000) / 10000;
|
||||
}
|
||||
|
||||
// Skip the CLI call when broad-pass already collected the same metric grouped
|
||||
// by [route, dim]. Returns {rows} on hit, null on miss. Cuts rate-limit pressure
|
||||
// for per-route slice specs (startTypeSplit, cacheBreakdown, methodDistribution).
|
||||
function tryExtractFromBroadPass(spec, merged) {
|
||||
const eq = spec.broadPassEquivalent;
|
||||
if (!eq) return null;
|
||||
const broadRows = merged?.metrics?.[eq.key]?.rows;
|
||||
if (!Array.isArray(broadRows)) return null;
|
||||
const rows = [];
|
||||
for (const row of broadRows) {
|
||||
if (row.route !== eq.routeFilter) continue;
|
||||
const out = { value: typeof row.value === 'number' ? row.value : null };
|
||||
for (const dim of (eq.projectDims ?? [])) {
|
||||
if (row[dim] !== undefined) out[dim] = row[dim];
|
||||
}
|
||||
rows.push(out);
|
||||
}
|
||||
// Zero rows ≠ "no data" — broad-pass row limit may have truncated the route.
|
||||
// Fall through to CLI so the caller gets a definitive answer.
|
||||
if (rows.length === 0) return null;
|
||||
return { rows };
|
||||
}
|
||||
|
||||
// Two specs sharing this key answer the same question — one CLI call serves both.
|
||||
// Must include everything that affects the CLI's arg list.
|
||||
function queryKey(spec, scope) {
|
||||
const groupBy = [...(spec.groupBy ?? [])].sort();
|
||||
return JSON.stringify({
|
||||
metricId: spec.metricId,
|
||||
aggregation: spec.aggregation,
|
||||
groupBy,
|
||||
filter: spec.filter ?? null,
|
||||
since: spec.since ?? null,
|
||||
limit: spec.limit ?? null,
|
||||
scope: scope ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[deep-dive] FAILED:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env node
|
||||
// Pure-JS deterministic gate. Reads merged signals.json, emits
|
||||
// {toLaunch, platform, gated}. Same input → byte-identical output (modulo
|
||||
// appliedAt). Sort keys are stable and explicit — never change without
|
||||
// a co-located golden-output test update.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { gates, DEFAULT_MAX_CODE_CANDIDATES, GATE_VERSION } from '../lib/gates/index.mjs';
|
||||
import { applyAuthDisqualifier } from '../lib/auth-route.mjs';
|
||||
import { dedupeCandidates } from '../lib/route-normalize.mjs';
|
||||
import { validateCandidates } from '../lib/gates/contract.mjs';
|
||||
import { applyHardGates } from '../lib/gates/hard-gates.mjs';
|
||||
import { selectLaunchCandidates } from '../lib/gates/select-candidates.mjs';
|
||||
import { routePathMatchScore } from '../lib/investigation-brief.mjs';
|
||||
|
||||
const SCHEMA_VERSION = '1.1';
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.signalsPath) {
|
||||
console.error('usage: node scripts/gate-investigations.mjs <signals.json> [--max-candidates N|all]');
|
||||
console.error(' VERCEL_OPTIMIZE_MAX_CANDIDATES env var supported (same values)');
|
||||
process.exit(1);
|
||||
}
|
||||
const budget = resolveBudget(args);
|
||||
const signals = JSON.parse(await readFile(args.signalsPath, 'utf-8'));
|
||||
|
||||
const allSeeds = gates.flatMap((g) => {
|
||||
try {
|
||||
return g.gate(signals) ?? [];
|
||||
} catch (err) {
|
||||
console.error(`[gate-investigations] gate ${g.metadata?.id} threw: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const validSeeds = validateCandidates(allSeeds, { source: 'gate-output' });
|
||||
const annotated = validSeeds.map(applyAuthDisqualifier);
|
||||
const sorted = annotated.slice().sort(stableCompare);
|
||||
|
||||
// Next.js 16 segment-tree metric paths surface the same source file under
|
||||
// many encoded labels (city variants, _tree/_index siblings, base64 flag
|
||||
// prefixes). Without dedup the budget gets shredded ~4-10x per page.
|
||||
const { deduped, dropped } = dedupeCandidates(sorted);
|
||||
const displayAnnotated = deduped.map((candidate) => attachDisplayRoute(candidate, signals));
|
||||
const hardGateResult = applyHardGates(displayAnnotated, signals);
|
||||
const gateable = hardGateResult.allowed;
|
||||
|
||||
// Account-scope candidates don't compete with code-scope for the budget.
|
||||
const codeScoped = gateable.filter((c) => !c.disqualified && c.scope !== 'account');
|
||||
const platformScoped = gateable.filter((c) => !c.disqualified && c.scope === 'account');
|
||||
|
||||
const selection = selectLaunchCandidates(codeScoped, budget, {
|
||||
diversify: args.budgetSource === 'default',
|
||||
});
|
||||
const toLaunch = selection.selected;
|
||||
const skippedByBudget = selection.skipped;
|
||||
const budgetLabel = budget === Infinity ? 'unlimited (all)' : String(budget);
|
||||
const gated = [
|
||||
...gateable
|
||||
.filter((c) => c.disqualified)
|
||||
.map((c) => ({ ...c, gatedReason: c.disqualifyReason ?? 'disqualified' })),
|
||||
...hardGateResult.gated,
|
||||
...skippedByBudget.map((c) => ({
|
||||
...c,
|
||||
gatedReason: `skippedByBudget (max-candidates=${budgetLabel}; raise with --max-candidates N or =all)`,
|
||||
})),
|
||||
...dropped.map((d) => ({
|
||||
...d.candidate,
|
||||
gatedReason: `coveredBy (${d.mergedInto}) — ${d.reason}`,
|
||||
})),
|
||||
];
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
gateVersion: GATE_VERSION,
|
||||
appliedAt: new Date().toISOString(),
|
||||
budget: {
|
||||
maxCandidates: budget === Infinity ? 'all' : budget,
|
||||
source: args.budgetSource,
|
||||
selection: selection.selectionMode,
|
||||
},
|
||||
toLaunch,
|
||||
platform: platformScoped,
|
||||
gated,
|
||||
gateMetadata: gates.map((g) => ({
|
||||
id: g.metadata?.id,
|
||||
threshold: g.metadata?.threshold,
|
||||
billingDimension: g.metadata?.billingDimension,
|
||||
sourceCitation: g.metadata?.sourceCitation,
|
||||
})),
|
||||
}, null, 2) + '\n');
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { positional: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--max-candidates') out.maxCandidatesArg = argv[++i];
|
||||
else if (a.startsWith('--max-candidates=')) out.maxCandidatesArg = a.slice('--max-candidates='.length);
|
||||
else out.positional.push(a);
|
||||
}
|
||||
out.signalsPath = out.positional[0];
|
||||
return out;
|
||||
}
|
||||
|
||||
function resolveBudget(args) {
|
||||
const raw = args.maxCandidatesArg ?? process.env.VERCEL_OPTIMIZE_MAX_CANDIDATES;
|
||||
if (raw == null || raw === '') {
|
||||
args.budgetSource = 'default';
|
||||
return DEFAULT_MAX_CODE_CANDIDATES;
|
||||
}
|
||||
const trimmed = String(raw).trim().toLowerCase();
|
||||
if (trimmed === 'all' || trimmed === 'unlimited' || trimmed === '-1') {
|
||||
args.budgetSource = args.maxCandidatesArg != null ? 'flag' : 'env';
|
||||
return Infinity;
|
||||
}
|
||||
const n = Number(trimmed);
|
||||
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
|
||||
console.error(`[gate-investigations] bad budget value '${raw}'; expected positive integer or 'all'`);
|
||||
process.exit(2);
|
||||
}
|
||||
args.budgetSource = args.maxCandidatesArg != null ? 'flag' : 'env';
|
||||
return n;
|
||||
}
|
||||
|
||||
// Total ordering: priority desc, kind asc, route asc. Underpins byte-identical output.
|
||||
function stableCompare(a, b) {
|
||||
const pa = a.priority ?? 0;
|
||||
const pb = b.priority ?? 0;
|
||||
if (pa !== pb) return pb - pa;
|
||||
const ka = String(a.kind ?? '');
|
||||
const kb = String(b.kind ?? '');
|
||||
if (ka !== kb) return ka.localeCompare(kb);
|
||||
const ra = String(a.route ?? a.hostname ?? '');
|
||||
const rb = String(b.route ?? b.hostname ?? '');
|
||||
return ra.localeCompare(rb);
|
||||
}
|
||||
|
||||
function attachDisplayRoute(candidate, signals) {
|
||||
if (!candidate || candidate.scope !== 'route' || typeof candidate.route !== 'string') return candidate;
|
||||
if (!candidate.route.includes('[*]')) return candidate;
|
||||
|
||||
const routes = (signals.codebase?.routes ?? [])
|
||||
.map((route) => route?.routePath)
|
||||
.filter((routePath) => typeof routePath === 'string' && routePath.length > 0);
|
||||
if (routes.length === 0) return candidate;
|
||||
|
||||
let bestRoute = null;
|
||||
let bestScore = 0;
|
||||
for (const routePath of routes) {
|
||||
const score = routePathMatchScore(routePath, candidate.route);
|
||||
if (score > bestScore) {
|
||||
bestRoute = routePath;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestRoute || bestScore <= 0 || bestRoute === candidate.route) return candidate;
|
||||
return { ...candidate, displayRoute: bestRoute };
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[gate-investigations] FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env node
|
||||
// Deterministically combines Vercel metric collection with the local codebase
|
||||
// scan. Keeps the merged artifact shape stable: collect-signals output at the
|
||||
// top level, scan-codebase output under `codebase`.
|
||||
|
||||
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { routePathMatchScore } from '../lib/investigation-brief.mjs';
|
||||
import { canonicalizeRoute } from '../lib/route-normalize.mjs';
|
||||
|
||||
const log = (...args) => console.error('[merge-signals]', ...args);
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.signalsPath || !args.codebasePath) {
|
||||
console.error('usage: node scripts/merge-signals.mjs <signals.json> <codebase.json> [--out merged.json] [--force]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [signals, codebase] = await Promise.all([
|
||||
readJson(args.signalsPath, 'signals'),
|
||||
readJson(args.codebasePath, 'codebase scan'),
|
||||
]);
|
||||
|
||||
const merged = mergeSignals(signals, codebase);
|
||||
const body = JSON.stringify(merged, null, 2) + '\n';
|
||||
if (args.outPath) {
|
||||
await writeOutput(args.outPath, body, { force: args.force });
|
||||
log(`wrote ${args.outPath}`);
|
||||
} else {
|
||||
process.stdout.write(body);
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeSignals(signals, codebase) {
|
||||
assertObject(signals, 'signals');
|
||||
assertObject(codebase, 'codebase scan');
|
||||
|
||||
if (!signals.schemaVersion) {
|
||||
throw new Error('signals.json is missing schemaVersion; pass collect-signals output as the first file.');
|
||||
}
|
||||
if (!Array.isArray(codebase.routes) || !Array.isArray(codebase.findings) || !codebase.stack) {
|
||||
throw new Error('codebase.json must be scan-codebase output with stack, routes[], and findings[].');
|
||||
}
|
||||
|
||||
return {
|
||||
...signals,
|
||||
codebase: annotateCodebaseScan(signals, codebase),
|
||||
};
|
||||
}
|
||||
|
||||
export function annotateCodebaseScan(signals, codebase) {
|
||||
const index = buildRouteMetricIndex(signals);
|
||||
return {
|
||||
...codebase,
|
||||
findings: (codebase.findings ?? []).map((finding) => annotateFinding(finding, index)),
|
||||
};
|
||||
}
|
||||
|
||||
function annotateFinding(finding, index) {
|
||||
if (!finding || typeof finding !== 'object') return finding;
|
||||
if (finding.trafficIndependent) return finding;
|
||||
if (!finding.route) return { ...finding, o11ySignal: 'NO-ROUTE-MAPPING' };
|
||||
|
||||
const summary = bestRouteSummary(finding.route, index);
|
||||
if (!summary || !hasTraffic(summary)) return { ...finding, o11ySignal: 'COLD-PATH' };
|
||||
return { ...finding, o11ySignal: formatRouteSignal(summary) };
|
||||
}
|
||||
|
||||
function buildRouteMetricIndex(signals) {
|
||||
const out = new Map();
|
||||
const ensure = (route) => {
|
||||
const canonical = canonicalizeRoute(route);
|
||||
const existing = out.get(canonical) ?? { route: canonical };
|
||||
out.set(canonical, existing);
|
||||
return existing;
|
||||
};
|
||||
|
||||
for (const row of rows(signals, 'fnStatusByRoute')) {
|
||||
if (!row.route) continue;
|
||||
const summary = ensure(row.route);
|
||||
summary.functionRuns = (summary.functionRuns ?? 0) + numeric(row.value);
|
||||
}
|
||||
for (const row of rows(signals, 'fnDurationP95ByRoute')) {
|
||||
if (!row.route) continue;
|
||||
ensure(row.route).p95Ms = numeric(row.value);
|
||||
}
|
||||
for (const row of rows(signals, 'requestsByRouteCache')) {
|
||||
if (!row.route) continue;
|
||||
const summary = ensure(row.route);
|
||||
const count = numeric(row.value);
|
||||
summary.requests = (summary.requests ?? 0) + count;
|
||||
if (String(row.cache_result).toUpperCase() === 'HIT') {
|
||||
summary.cacheHits = (summary.cacheHits ?? 0) + count;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rows(signals, metricId) {
|
||||
const rows = signals?.metrics?.[metricId]?.rows;
|
||||
return Array.isArray(rows) ? rows : [];
|
||||
}
|
||||
|
||||
function numeric(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function bestRouteSummary(route, index) {
|
||||
const canonical = canonicalizeRoute(route);
|
||||
const exact = index.get(canonical);
|
||||
if (exact) return exact;
|
||||
|
||||
let best = null;
|
||||
for (const summary of index.values()) {
|
||||
const score = routePathMatchScore(canonical, summary.route);
|
||||
if (score <= 0) continue;
|
||||
if (!best || score > best.score) best = { score, summary };
|
||||
}
|
||||
return best?.summary ?? null;
|
||||
}
|
||||
|
||||
function hasTraffic(summary) {
|
||||
return (summary.functionRuns ?? 0) > 0 || (summary.requests ?? 0) > 0;
|
||||
}
|
||||
|
||||
function formatRouteSignal(summary) {
|
||||
const parts = [];
|
||||
if ((summary.functionRuns ?? 0) > 0) parts.push(`inv=${Math.round(summary.functionRuns)}`);
|
||||
else if ((summary.requests ?? 0) > 0) parts.push(`requests=${Math.round(summary.requests)}`);
|
||||
if ((summary.p95Ms ?? 0) > 0) parts.push(`p95=${Math.round(summary.p95Ms)}ms`);
|
||||
if ((summary.requests ?? 0) > 0 && summary.cacheHits != null) {
|
||||
const hitRate = Math.round((summary.cacheHits / summary.requests) * 100);
|
||||
parts.push(`cache=${hitRate}%`);
|
||||
}
|
||||
return parts.join(',') || 'COLD-PATH';
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { positional: [], force: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--out') out.outPath = argv[++i];
|
||||
else if (a.startsWith('--out=')) out.outPath = a.slice('--out='.length);
|
||||
else if (a === '--force') out.force = true;
|
||||
else out.positional.push(a);
|
||||
}
|
||||
out.signalsPath = out.positional[0];
|
||||
out.codebasePath = out.positional[1];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readJson(path, label) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf-8'));
|
||||
} catch (err) {
|
||||
throw new Error(`Could not read ${label} JSON at ${path}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertObject(value, label) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${label} must be a JSON object.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeOutput(path, body, { force }) {
|
||||
if (!force && await exists(path)) {
|
||||
throw new Error(`output file already exists: ${path}. Use a fresh run directory or pass --force to overwrite.`);
|
||||
}
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
await writeFile(path, body);
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
main().catch((err) => {
|
||||
console.error('[merge-signals] FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
// Emits the ENTIRE prompt a sub-agent sees for one candidate (candidate +
|
||||
// deep-dive evidence + filtered citations + playbook + protocol + output
|
||||
// schema). --list emits a manifest the orchestrator uses to decide fan-out
|
||||
// vs serial. Brief → stdout, status → stderr.
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import {
|
||||
buildBrief,
|
||||
inferPlaybook,
|
||||
inferFrameworkPlaybook,
|
||||
resolveFiles,
|
||||
citationSubset,
|
||||
} from '../lib/investigation-brief.mjs';
|
||||
import { supportTopicSubset } from '../lib/support-topics.mjs';
|
||||
import { candidateRefFor } from '../lib/reconcile-candidates.mjs';
|
||||
import { formatCandidateLabel } from '../lib/display-labels.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PLAYBOOKS_DIR = join(HERE, '..', 'references', 'playbooks');
|
||||
|
||||
const log = (...a) => console.error('[prepare-brief]', ...a);
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.mergedPath || !args.investigationPath) {
|
||||
console.error('usage: node scripts/prepare-investigation-brief.mjs <merged.json> <investigation.json> [--index N] [--group toLaunch|platform] [--out FILE]');
|
||||
console.error(' or: node scripts/prepare-investigation-brief.mjs <merged.json> <investigation.json> --list');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [merged, investigation] = await Promise.all([
|
||||
readFile(args.mergedPath, 'utf-8').then(JSON.parse),
|
||||
readFile(args.investigationPath, 'utf-8').then(JSON.parse),
|
||||
]);
|
||||
|
||||
if (args.list) {
|
||||
const manifest = buildManifest(merged, investigation);
|
||||
process.stdout.write(JSON.stringify(manifest, null, 2) + '\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const group = args.group ?? 'toLaunch';
|
||||
const index = args.index ?? 0;
|
||||
const pool = Array.isArray(investigation[group]) ? investigation[group] : [];
|
||||
if (index < 0 || index >= pool.length) {
|
||||
console.error(`[prepare-brief] FATAL: ${group}[${index}] out of range (${group} has ${pool.length} entries)`);
|
||||
process.exit(2);
|
||||
}
|
||||
let candidate = pool[index];
|
||||
|
||||
// Scan output may live at merged.codebase (older shape) or merged.signals.codebase
|
||||
// (current shape, after the jq merge nests it under signals). Resolve either.
|
||||
const codebase = pickCodebase(merged);
|
||||
const signals = {
|
||||
...merged,
|
||||
codebase,
|
||||
};
|
||||
const files = resolveFiles(candidate, signals);
|
||||
candidate = {
|
||||
...candidate,
|
||||
candidateRef: candidate.candidateRef ?? candidateRefFor(candidate, files),
|
||||
};
|
||||
const playbookId = inferPlaybook(signals);
|
||||
const playbookBody = playbookId ? await tryReadPlaybook(playbookId) : null;
|
||||
const frameworkPlaybookId = inferFrameworkPlaybook(signals);
|
||||
const frameworkPlaybookBody = frameworkPlaybookId ? await tryReadPlaybook(frameworkPlaybookId) : null;
|
||||
|
||||
const stack = signals.stack ?? signals.codebase?.stack ?? {};
|
||||
const framework = stack.framework ?? 'unknown';
|
||||
const version = stack.frameworkVersion ?? 'unknown';
|
||||
const citations = await citationSubset(candidate.kind, framework, version);
|
||||
const supportTopics = await supportTopicSubset({
|
||||
candidate,
|
||||
signals,
|
||||
framework,
|
||||
version,
|
||||
profile: playbookId,
|
||||
frameworkPlaybookId,
|
||||
});
|
||||
|
||||
const brief = buildBrief({
|
||||
candidate,
|
||||
candidateIndex: index,
|
||||
candidateGroup: group,
|
||||
files,
|
||||
signals,
|
||||
citations,
|
||||
playbookId,
|
||||
playbookBody,
|
||||
frameworkPlaybookId,
|
||||
frameworkPlaybookBody,
|
||||
supportTopics,
|
||||
generatedAt: args.deterministic ? null : new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (args.outPath) {
|
||||
await mkdir(dirname(args.outPath), { recursive: true });
|
||||
await writeBriefFile(args.outPath, brief, { force: args.force });
|
||||
log(`wrote ${brief.length}B → ${args.outPath}`);
|
||||
} else {
|
||||
process.stdout.write(brief + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function buildManifest(merged, investigation) {
|
||||
const out = [];
|
||||
const groups = ['toLaunch', 'platform'];
|
||||
for (const group of groups) {
|
||||
const pool = Array.isArray(investigation[group]) ? investigation[group] : [];
|
||||
pool.forEach((c, i) => {
|
||||
const files = resolveFiles(c, { ...merged, codebase: pickCodebase(merged) });
|
||||
const candidateRef = c.candidateRef ?? candidateRefFor(c, files);
|
||||
out.push({
|
||||
group,
|
||||
index: i,
|
||||
kind: c.kind,
|
||||
route: c.route ?? c.hostname ?? null,
|
||||
scope: c.scope ?? null,
|
||||
priority: c.priority ?? null,
|
||||
confidence: c.confidence ?? null,
|
||||
o11ySignal: c.o11ySignal ?? null,
|
||||
files,
|
||||
candidateRef,
|
||||
label: formatCandidateLabel({ ...c, files }),
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
totalBriefs: out.length,
|
||||
toLaunchCount: out.filter((b) => b.group === 'toLaunch').length,
|
||||
platformCount: out.filter((b) => b.group === 'platform').length,
|
||||
preResolvedRecords: Array.isArray(investigation.preResolvedRecords)
|
||||
? investigation.preResolvedRecords
|
||||
: [],
|
||||
fanoutPlan: buildFanoutPlan(out),
|
||||
briefs: out,
|
||||
};
|
||||
}
|
||||
|
||||
function buildFanoutPlan(briefs) {
|
||||
const groups = new Map();
|
||||
for (const brief of briefs) {
|
||||
const key = candidateFamilyKey(brief);
|
||||
const existing = groups.get(key) ?? {
|
||||
familyKey: key,
|
||||
label: brief.label,
|
||||
kind: brief.kind,
|
||||
primaryBrief: { group: brief.group, index: brief.index, candidateRef: brief.candidateRef },
|
||||
relatedBriefs: [],
|
||||
};
|
||||
if (existing.primaryBrief.candidateRef !== brief.candidateRef) {
|
||||
existing.relatedBriefs.push({ group: brief.group, index: brief.index, candidateRef: brief.candidateRef });
|
||||
}
|
||||
groups.set(key, existing);
|
||||
}
|
||||
return {
|
||||
totalFamilies: groups.size,
|
||||
families: [...groups.values()].map((g) => ({
|
||||
...g,
|
||||
totalBriefs: 1 + g.relatedBriefs.length,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function candidateFamilyKey(brief) {
|
||||
const file = Array.isArray(brief.files) && brief.files.length > 0 ? brief.files[0] : null;
|
||||
const target = file ?? brief.route ?? brief.scope ?? '<account>';
|
||||
return `${brief.kind ?? 'unknown'}:${target}`;
|
||||
}
|
||||
|
||||
// Prefer merged.codebase, fall back to merged.signals.codebase, then empty.
|
||||
// Also accepts a fully-shaped scan doc directly (used in tests).
|
||||
function pickCodebase(merged) {
|
||||
if (!merged || typeof merged !== 'object') return {};
|
||||
if (merged.codebase && typeof merged.codebase === 'object' && (merged.codebase.routes || merged.codebase.findings)) {
|
||||
return merged.codebase;
|
||||
}
|
||||
if (merged.signals?.codebase && typeof merged.signals.codebase === 'object') {
|
||||
return merged.signals.codebase;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function tryReadPlaybook(id) {
|
||||
try {
|
||||
return await readFile(join(PLAYBOOKS_DIR, `${id}.md`), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { positional: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--index') out.index = Number(argv[++i]);
|
||||
else if (a.startsWith('--index=')) out.index = Number(a.slice('--index='.length));
|
||||
else if (a === '--group') out.group = argv[++i];
|
||||
else if (a.startsWith('--group=')) out.group = a.slice('--group='.length);
|
||||
else if (a === '--out') out.outPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--out=')) out.outPath = resolve(a.slice('--out='.length));
|
||||
else if (a === '--list') out.list = true;
|
||||
else if (a === '--deterministic') out.deterministic = true;
|
||||
else if (a === '--force') out.force = true;
|
||||
else out.positional.push(a);
|
||||
}
|
||||
out.mergedPath = out.positional[0];
|
||||
out.investigationPath = out.positional[1];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function writeBriefFile(outPath, brief, { force = false } = {}) {
|
||||
try {
|
||||
await writeFile(outPath, brief + '\n', { encoding: 'utf-8', flag: force ? 'w' : 'wx' });
|
||||
} catch (err) {
|
||||
if (err?.code === 'EEXIST') {
|
||||
throw new Error(`output file already exists: ${outPath}. Use a fresh run directory or pass --force to overwrite.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[prepare-brief] FAILED:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// Deterministic reconciliation between deep-dive and investigator fan-out.
|
||||
// Reads investigation-evidence.json, removes candidates whose follow-up metric
|
||||
// evidence already disproves/reframes the gate hypothesis, and emits the same
|
||||
// shape with preResolvedRecords for the final report.
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { reconcileInvestigation } from '../lib/reconcile-candidates.mjs';
|
||||
|
||||
const log = (...a) => console.error('[reconcile-candidates]', ...a);
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.investigationPath) {
|
||||
console.error('usage: node scripts/reconcile-candidates.mjs <investigation-evidence.json> [--gate gate.json] [--out reconciled-investigation.json]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [investigation, gate] = await Promise.all([
|
||||
readFile(args.investigationPath, 'utf-8').then(JSON.parse),
|
||||
args.gatePath ? readFile(args.gatePath, 'utf-8').then(JSON.parse) : null,
|
||||
]);
|
||||
|
||||
const reconciled = reconcileInvestigation(investigation, { gate });
|
||||
const serialized = JSON.stringify({
|
||||
...reconciled,
|
||||
reconciledAt: args.noTimestamp ? null : new Date().toISOString(),
|
||||
}, null, 2) + '\n';
|
||||
|
||||
if (args.outPath) {
|
||||
await mkdir(dirname(args.outPath), { recursive: true });
|
||||
await writeFile(args.outPath, serialized, 'utf-8');
|
||||
log(`wrote ${serialized.length}B -> ${args.outPath}`);
|
||||
} else {
|
||||
process.stdout.write(serialized);
|
||||
}
|
||||
|
||||
const dropped = reconciled.reconciliation?.droppedBeforeInvestigation ?? 0;
|
||||
if (dropped > 0) log(`dropped ${dropped} candidate(s) before investigation`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { positional: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--gate') out.gatePath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--gate=')) out.gatePath = resolve(a.slice('--gate='.length));
|
||||
else if (a === '--out') out.outPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--out=')) out.outPath = resolve(a.slice('--out='.length));
|
||||
else if (a === '--no-timestamp') out.noTimestamp = true;
|
||||
else out.positional.push(a);
|
||||
}
|
||||
out.investigationPath = out.positional[0] ? resolve(out.positional[0]) : null;
|
||||
return out;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[reconcile-candidates] FAILED:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,437 @@
|
||||
#!/usr/bin/env node
|
||||
// Final pipeline step. Emits customer-facing markdown from
|
||||
// recommendations.json + gate.json + signals.json.
|
||||
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { buildFinalReportMessage, renderReport } from '../lib/render-report.mjs';
|
||||
import { dedupeRecommendations } from '../lib/dedup-recs.mjs';
|
||||
import { canonicalizeRoute } from '../lib/route-normalize.mjs';
|
||||
import { hasUnsupportedCacheLifeCdnText, splitCustomerSafeObservations } from '../lib/observation-safety.mjs';
|
||||
|
||||
const log = (...a) => console.error('[render-report]', ...a);
|
||||
const HARD_REGEN_TRIGGERS = new Set([
|
||||
'project_config_contradiction',
|
||||
'cache_vary_safety',
|
||||
'semantic_safety',
|
||||
]);
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.recsPath || !args.gatePath || !args.signalsPath) {
|
||||
console.error('usage: node scripts/render-report.mjs <recommendations.json> <gate.json> <signals.json> [--project NAME] [--out FILE] [--message-out FILE] [--no-timestamp] [--debug-out FILE]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [recsRaw, gateRaw, signalsRaw] = await Promise.all([
|
||||
readFile(args.recsPath, 'utf-8').then(JSON.parse),
|
||||
readFile(args.gatePath, 'utf-8').then(JSON.parse),
|
||||
readFile(args.signalsPath, 'utf-8').then(JSON.parse),
|
||||
]);
|
||||
|
||||
// Accept either a raw rec array OR the verify-and-regen wrapper
|
||||
// {recsGraded, qualityDropped, ...}. Stale-rec defense: when verify-and-regen
|
||||
// flagged a hard-safety issue but the orchestrator skipped re-spawn, the
|
||||
// original rec is still in recsGraded. Filter it here so it can't ship; it
|
||||
// surfaces in "Investigated, no change recommended" instead.
|
||||
const hardRegenRefs = new Set(
|
||||
Array.isArray(recsRaw.regenPlan)
|
||||
? recsRaw.regenPlan
|
||||
.filter((p) => HARD_REGEN_TRIGGERS.has(p.regenTrigger))
|
||||
.map((p) => p.candidateRef)
|
||||
.filter(Boolean)
|
||||
: []
|
||||
);
|
||||
const activeCandidates = [
|
||||
...(Array.isArray(gateRaw.toLaunch) ? gateRaw.toLaunch : []),
|
||||
...(Array.isArray(gateRaw.platform) ? gateRaw.platform : []),
|
||||
];
|
||||
const enforceCurrentGate = !Array.isArray(recsRaw) && activeCandidates.length > 0;
|
||||
const staleRecommendationDrops = [];
|
||||
const wrapperRecommendations = Array.isArray(recsRaw.renderableRecommendations)
|
||||
? recsRaw.renderableRecommendations
|
||||
: (recsRaw.recsGraded ?? []);
|
||||
const needsReviewDrops = [];
|
||||
const candidateRecommendations = Array.isArray(recsRaw)
|
||||
? recsRaw.filter((r) => r?.abstain !== true)
|
||||
: wrapperRecommendations
|
||||
.filter((r, i) => (r.quality?.overall ?? 0) >= 0.55)
|
||||
.filter((r) => !hardRegenRefs.has(r.candidateRef));
|
||||
const recommendationsRaw = candidateRecommendations
|
||||
.filter((r) => {
|
||||
if (r?.abstain === true || r?.needsReview !== true) return true;
|
||||
needsReviewDrops.push({
|
||||
candidateRef: r.candidateRef ?? null,
|
||||
reason: 'This recommendation needs a manual safety review before it is ready to apply.',
|
||||
});
|
||||
return false;
|
||||
})
|
||||
.filter((r) => {
|
||||
if (!enforceCurrentGate) return true;
|
||||
if (recommendationMatchesActiveCandidate(r, activeCandidates)) return true;
|
||||
staleRecommendationDrops.push({
|
||||
candidateRef: r.candidateRef ?? null,
|
||||
reason: 'This recommendation came from a candidate that is not in the current run output. Re-run from a clean run directory before applying it.',
|
||||
});
|
||||
return false;
|
||||
});
|
||||
const recommendations = dedupeRecommendations(recommendationsRaw);
|
||||
const readyTargets = new Set(
|
||||
recommendations
|
||||
.map((r) => candidateTarget(r?.candidateRef))
|
||||
.filter(Boolean)
|
||||
);
|
||||
const droppedContradictions = !Array.isArray(recsRaw)
|
||||
? (recsRaw.recsGraded ?? [])
|
||||
.map((r, i) => ({ r, i }))
|
||||
.filter(({ r }) => hardRegenRefs.has(r.candidateRef))
|
||||
.map(({ r, i }) => ({
|
||||
candidateRef: r.candidateRef ?? null,
|
||||
reason: publicHardRegenReason(recsRaw.regenPlan?.find((p) => p.index === i || p.candidateRef === r.candidateRef)),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const gated = Array.isArray(gateRaw.gated) ? gateRaw.gated : [];
|
||||
|
||||
// No-change findings are first-class investigation outputs ("the hypothesis didn't hold").
|
||||
// Contradiction-dropped recs ride alongside them so customers see WHY a rec
|
||||
// was held back instead of it silently disappearing.
|
||||
const baseAbstentions = Array.isArray(recsRaw)
|
||||
? recsRaw.filter((r) => r?.abstain === true).map((r) => ({
|
||||
candidateRef: r.candidateRef ?? null,
|
||||
reason: publicNoChangeReason(r.reason ?? '(no reason recorded)'),
|
||||
}))
|
||||
: (recsRaw.abstentions ?? []).map((r) => ({
|
||||
...r,
|
||||
reason: publicNoChangeReason(r.reason ?? '(no reason recorded)'),
|
||||
}));
|
||||
const publicBaseAbstentions = baseAbstentions.filter((r) => !readyTargets.has(candidateTarget(r?.candidateRef)));
|
||||
// Observations: no-change findings carrying a structured non-perf finding
|
||||
// (deployment regression, error storm, etc.).
|
||||
const flattenedObservations = Array.isArray(recsRaw)
|
||||
? flattenObservations(recsRaw.filter((r) => r?.abstain === true))
|
||||
: flattenObservations([
|
||||
...(Array.isArray(recsRaw.observations) ? recsRaw.observations : []),
|
||||
...(Array.isArray(recsRaw.abstentions) ? recsRaw.abstentions : []),
|
||||
]);
|
||||
const { observations: safeObservations, heldBackObservations } = splitCustomerSafeObservations(flattenedObservations, baseAbstentions, signalsRaw);
|
||||
const observations = suppressReadyCoveredObservations(safeObservations, recommendations);
|
||||
|
||||
const abstentions = [
|
||||
...publicBaseAbstentions,
|
||||
...droppedContradictions,
|
||||
...staleRecommendationDrops,
|
||||
...needsReviewDrops,
|
||||
...(Array.isArray(recsRaw.withheldRecommendations) ? recsRaw.withheldRecommendations.map((d) => ({
|
||||
candidateRef: d.candidateRef ?? null,
|
||||
reason: publicWithheldReason(d),
|
||||
needsEvidence: true,
|
||||
})) : []),
|
||||
...(Array.isArray(recsRaw.sanitizerDropped) ? recsRaw.sanitizerDropped.map((d) => ({
|
||||
candidateRef: d.candidateRef ?? null,
|
||||
reason: `This needs a closer review before it is safe to apply: ${d.reason ?? 'review required'}.`,
|
||||
needsEvidence: true,
|
||||
})) : []),
|
||||
...(Array.isArray(recsRaw.heldBackObservations) ? recsRaw.heldBackObservations.map((d) => ({
|
||||
...d,
|
||||
needsEvidence: true,
|
||||
})) : []),
|
||||
...heldBackObservations,
|
||||
];
|
||||
|
||||
// Full catalog lets the renderer recover o11ySignal + aliasRoutes that recs
|
||||
// didn't propagate, and canonicalize segment-tree candidateRefs.
|
||||
const allCandidates = [
|
||||
...activeCandidates,
|
||||
...gated,
|
||||
];
|
||||
|
||||
const md = renderReport({
|
||||
recommendations,
|
||||
gated,
|
||||
abstentions,
|
||||
observations,
|
||||
signals: signalsRaw,
|
||||
candidates: allCandidates,
|
||||
opts: {
|
||||
projectName: args.projectName,
|
||||
generatedAt: args.noTimestamp ? null : new Date().toISOString(),
|
||||
heldBackCount: (Number.isInteger(recsRaw.summary?.withheldRecommendations)
|
||||
? recsRaw.summary.withheldRecommendations
|
||||
: (Array.isArray(recsRaw.regenPlan) ? recsRaw.regenPlan.length : 0) +
|
||||
(Array.isArray(recsRaw.qualityDropped) ? recsRaw.qualityDropped.length : 0)) +
|
||||
(Array.isArray(recsRaw.sanitizerDropped) ? recsRaw.sanitizerDropped.length : 0) +
|
||||
(Array.isArray(recsRaw.heldBackObservations) ? recsRaw.heldBackObservations.length : 0) +
|
||||
heldBackObservations.length,
|
||||
noChangeCount: Number.isInteger(recsRaw.summary?.abstentions)
|
||||
? Math.min(recsRaw.summary.abstentions, publicBaseAbstentions.length)
|
||||
: publicBaseAbstentions.length,
|
||||
},
|
||||
});
|
||||
|
||||
if (args.debugOutPath) {
|
||||
const debugArtifact = buildDebugArtifact({
|
||||
recsRaw,
|
||||
recommendationsRaw,
|
||||
recommendations,
|
||||
gateRaw,
|
||||
abstentions,
|
||||
observations,
|
||||
heldBackObservations,
|
||||
staleRecommendationDrops,
|
||||
droppedContradictions,
|
||||
});
|
||||
const serializedDebug = JSON.stringify(debugArtifact, null, 2) + '\n';
|
||||
await mkdir(dirname(args.debugOutPath), { recursive: true });
|
||||
await writeFile(args.debugOutPath, serializedDebug, 'utf-8');
|
||||
log(`wrote debug ${serializedDebug.length}B → ${args.debugOutPath}`);
|
||||
}
|
||||
|
||||
if (args.messageOutPath) {
|
||||
const messageArtifact = buildFinalReportMessage({
|
||||
reportPath: args.outPath ?? '(stdout)',
|
||||
markdown: md,
|
||||
recommendations,
|
||||
signals: signalsRaw,
|
||||
});
|
||||
const serializedMessage = JSON.stringify(messageArtifact, null, 2) + '\n';
|
||||
await mkdir(dirname(args.messageOutPath), { recursive: true });
|
||||
await writeFile(args.messageOutPath, serializedMessage, 'utf-8');
|
||||
log(`wrote final message ${serializedMessage.length}B → ${args.messageOutPath}`);
|
||||
}
|
||||
|
||||
if (args.outPath) {
|
||||
await mkdir(dirname(args.outPath), { recursive: true });
|
||||
await writeFile(args.outPath, md + '\n', 'utf-8');
|
||||
log(`wrote ${md.length}B → ${args.outPath}`);
|
||||
} else {
|
||||
process.stdout.write(md + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { positional: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--project') out.projectName = argv[++i];
|
||||
else if (a.startsWith('--project=')) out.projectName = a.slice('--project='.length);
|
||||
else if (a === '--out') out.outPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--out=')) out.outPath = resolve(a.slice('--out='.length));
|
||||
else if (a === '--message-out') out.messageOutPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--message-out=')) out.messageOutPath = resolve(a.slice('--message-out='.length));
|
||||
else if (a === '--no-timestamp') out.noTimestamp = true;
|
||||
else if (a === '--debug-out') out.debugOutPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--debug-out=')) out.debugOutPath = resolve(a.slice('--debug-out='.length));
|
||||
else if (a === '--debug') {
|
||||
console.error('[render-report] --debug no longer writes internal details into customer markdown; use --debug-out FILE');
|
||||
}
|
||||
else out.positional.push(a);
|
||||
}
|
||||
out.recsPath = out.positional[0];
|
||||
out.gatePath = out.positional[1];
|
||||
out.signalsPath = out.positional[2];
|
||||
return out;
|
||||
}
|
||||
|
||||
function publicWithheldReason(record) {
|
||||
switch (record?.reason) {
|
||||
case 'needs_review':
|
||||
return 'Automated checks added a safety caveat, so this run kept the recommendation out of the ready-to-apply list.';
|
||||
case 'quality_floor':
|
||||
return 'The recommendation did not meet the evidence bar for this report.';
|
||||
case 'project_config_contradiction':
|
||||
case 'cache_vary_safety':
|
||||
case 'semantic_safety':
|
||||
return publicHardRegenReason({ regenTrigger: record.reason });
|
||||
default:
|
||||
return 'This recommendation needs stronger evidence before it is safe to apply.';
|
||||
}
|
||||
}
|
||||
|
||||
function publicHardRegenReason(plan) {
|
||||
switch (plan?.regenTrigger) {
|
||||
case 'project_config_contradiction':
|
||||
return 'The recommendation tried to turn on a project setting that is already enabled. Re-run the investigation with refreshed project-config evidence.';
|
||||
case 'cache_vary_safety':
|
||||
return 'The recommendation added shared CDN caching to output that varies by request geography without the required Vary header. Re-run the investigation with the cache-safety failure in scope.';
|
||||
case 'semantic_safety':
|
||||
return 'This recommendation needs stronger framework evidence before it is safe to apply. Re-run the investigation with that evidence in scope.';
|
||||
default:
|
||||
return 'This recommendation needs stronger evidence before it is safe to apply. Re-run the investigation with those checks in scope.';
|
||||
}
|
||||
}
|
||||
|
||||
function recommendationMatchesActiveCandidate(rec, candidates) {
|
||||
const ref = parseCandidateRef(rec?.candidateRef);
|
||||
if (!ref) return true;
|
||||
return candidates.some((candidate) => candidateMatchesRef(candidate, ref));
|
||||
}
|
||||
|
||||
function parseCandidateRef(ref) {
|
||||
if (typeof ref !== 'string' || ref.length === 0) return null;
|
||||
const [kind, ...targetParts] = ref.split(':');
|
||||
if (!kind) return null;
|
||||
return { kind, target: targetParts.join(':') };
|
||||
}
|
||||
|
||||
function candidateMatchesRef(candidate, ref) {
|
||||
if (!candidate || candidate.kind !== ref.kind) return false;
|
||||
if (candidate.scope === 'account' || ref.target === '<account>') return true;
|
||||
|
||||
const candidateTarget = candidate.route ?? candidate.hostname ?? candidate.file ?? candidate.target ?? null;
|
||||
if (!candidateTarget || !ref.target) return false;
|
||||
|
||||
const a = String(candidateTarget);
|
||||
const b = String(ref.target);
|
||||
return a === b || canonicalizeRoute(a) === canonicalizeRoute(b);
|
||||
}
|
||||
|
||||
function suppressReadyCoveredObservations(observations, recommendations = []) {
|
||||
if (!Array.isArray(observations) || observations.length === 0) return [];
|
||||
const readyFamiliesByTarget = new Map();
|
||||
for (const rec of recommendations) {
|
||||
const parsed = parseCandidateRef(rec?.candidateRef);
|
||||
const target = candidateTarget(rec?.candidateRef);
|
||||
const family = candidateFamily(parsed?.kind);
|
||||
if (!target || !family) continue;
|
||||
const set = readyFamiliesByTarget.get(target) ?? new Set();
|
||||
set.add(family);
|
||||
readyFamiliesByTarget.set(target, set);
|
||||
}
|
||||
|
||||
return observations.filter((observation) => {
|
||||
const parsed = parseCandidateRef(observation?.candidateRef);
|
||||
const target = candidateTarget(observation?.candidateRef);
|
||||
const family = candidateFamily(parsed?.kind);
|
||||
if (!target || !family) return true;
|
||||
return !readyFamiliesByTarget.get(target)?.has(family);
|
||||
});
|
||||
}
|
||||
|
||||
function candidateFamily(kind) {
|
||||
switch (kind) {
|
||||
case 'uncached_route':
|
||||
case 'cache_header_gap':
|
||||
case 'missing_cache_headers':
|
||||
case 'max_age_without_s_maxage':
|
||||
return 'cache';
|
||||
case 'slow_route':
|
||||
case 'cold_start':
|
||||
case 'external_api_slow':
|
||||
case 'cwv_poor':
|
||||
return 'performance';
|
||||
case 'route_errors':
|
||||
return 'reliability';
|
||||
case 'isr_overrevalidation':
|
||||
return 'isr';
|
||||
case 'middleware_heavy':
|
||||
return 'middleware';
|
||||
case 'build_minutes_fanout':
|
||||
return 'build';
|
||||
default:
|
||||
return kind || null;
|
||||
}
|
||||
}
|
||||
|
||||
function candidateTarget(ref) {
|
||||
if (typeof ref !== 'string') return null;
|
||||
const idx = ref.indexOf(':');
|
||||
if (idx === -1) return null;
|
||||
return ref.slice(idx + 1);
|
||||
}
|
||||
|
||||
function publicNoChangeReason(reason) {
|
||||
if (hasUnsupportedCacheLifeCdnText(reason)) {
|
||||
return 'This candidate overlapped a cache-lifetime draft that did not meet the framework evidence bar. No supported change shipped from this run.';
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
function buildDebugArtifact({
|
||||
recsRaw,
|
||||
recommendationsRaw,
|
||||
recommendations,
|
||||
gateRaw,
|
||||
abstentions = [],
|
||||
observations = [],
|
||||
heldBackObservations = [],
|
||||
staleRecommendationDrops = [],
|
||||
droppedContradictions = [],
|
||||
}) {
|
||||
const wrapper = Array.isArray(recsRaw) ? null : recsRaw;
|
||||
const sourceRecords = Array.isArray(recsRaw)
|
||||
? recsRaw
|
||||
: (recsRaw.recsGraded ?? []);
|
||||
const summary = wrapper?.summary
|
||||
? {
|
||||
...wrapper.summary,
|
||||
rawRecommendationCount: recommendationsRaw.length,
|
||||
renderedRecommendationCount: recommendations.length,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
summary,
|
||||
regenPlan: wrapper?.regenPlan ?? [],
|
||||
qualityDropped: wrapper?.qualityDropped ?? [],
|
||||
withheldRecommendations: wrapper?.withheldRecommendations ?? [],
|
||||
abstentions,
|
||||
observations,
|
||||
heldBackObservations,
|
||||
staleRecommendationDrops,
|
||||
droppedContradictions,
|
||||
sanitizerDropped: wrapper?.sanitizerDropped ?? [],
|
||||
renderedRecommendationCount: recommendations.length,
|
||||
rawRecommendationCount: recommendationsRaw.length,
|
||||
gateBudget: gateRaw?.budget ?? null,
|
||||
recommendations: sourceRecords
|
||||
.filter((record) => record && record.abstain !== true)
|
||||
.map((record) => ({
|
||||
candidateRef: record.candidateRef ?? null,
|
||||
what: record.what ?? null,
|
||||
verification: record.verification ?? null,
|
||||
quality: record.quality ?? null,
|
||||
passRate: record.passRate ?? record.verification?.passRate ?? null,
|
||||
avgQuality: record.avgQuality ?? null,
|
||||
needsReview: record.needsReview === true,
|
||||
sanitizerTrail: Array.isArray(record.sanitizerTrail) ? record.sanitizerTrail : [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function flattenObservations(records) {
|
||||
const out = [];
|
||||
for (const record of records) {
|
||||
if (!record || typeof record !== 'object') continue;
|
||||
if (record.observation && typeof record.observation === 'object') {
|
||||
out.push({
|
||||
candidateRef: record.candidateRef ?? null,
|
||||
summary: coerceOptionalString(record.observation.summary),
|
||||
evidence: record.observation.evidence ?? null,
|
||||
suggestedAction: record.observation.suggestedAction ?? null,
|
||||
kind: record.observation.kind ?? 'other',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if ('summary' in record || 'evidence' in record || 'suggestedAction' in record || 'kind' in record) {
|
||||
out.push({
|
||||
candidateRef: record.candidateRef ?? null,
|
||||
summary: coerceOptionalString(record.summary),
|
||||
evidence: record.evidence ?? null,
|
||||
suggestedAction: record.suggestedAction ?? null,
|
||||
kind: record.kind ?? 'other',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function coerceOptionalString(value) {
|
||||
return value == null ? value : String(value);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[render-report] FAILED:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
#!/usr/bin/env node
|
||||
// Walks the repo, runs every scanner in lib/scanners/, emits findings + routes
|
||||
// + stack as JSON. Output is merged into signals.codebase.*. New scanners drop
|
||||
// into lib/scanners/ + the barrel; this file is closed for modification.
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { join, relative } from 'node:path';
|
||||
import { scanners } from '../lib/scanners/index.mjs';
|
||||
import { detectStack } from '../lib/vercel.mjs';
|
||||
import {
|
||||
detectMonorepoRoot,
|
||||
listWorkspacePackages,
|
||||
buildResolver,
|
||||
resolveWorkspaceImports,
|
||||
} from '../lib/workspace-resolver.mjs';
|
||||
|
||||
const SCHEMA_VERSION = '1.0';
|
||||
const SKIP_DIRS = new Set(['node_modules', '.next', '.vercel', 'dist', 'build', '.git', 'coverage', '.turbo', '__tests__', 'cypress']);
|
||||
const SKIP_FILE_PATTERNS = [/\.test\./, /\.spec\./, /\.d\.ts$/];
|
||||
|
||||
async function main() {
|
||||
const rootDir = process.argv[2] || process.cwd();
|
||||
process.stderr.write(`[scan-codebase] scanning ${rootDir}\n`);
|
||||
|
||||
const [stack, files, routes] = await Promise.all([
|
||||
detectStack(rootDir),
|
||||
collectFiles(rootDir),
|
||||
enumerateRoutes(rootDir),
|
||||
]);
|
||||
|
||||
// In a monorepo, route files often re-export from workspace packages. Without
|
||||
// resolving those, sub-agents abstain because the workspace path is outside
|
||||
// their read scope.
|
||||
const monorepoRoot = await detectMonorepoRoot(rootDir);
|
||||
let workspacePackages = [];
|
||||
let resolver = () => null;
|
||||
if (monorepoRoot) {
|
||||
workspacePackages = await listWorkspacePackages(monorepoRoot);
|
||||
resolver = buildResolver(workspacePackages);
|
||||
process.stderr.write(`[scan-codebase] monorepo root: ${monorepoRoot} (${workspacePackages.length} workspace packages)\n`);
|
||||
}
|
||||
await enrichRoutesWithWorkspaceImports(routes, rootDir, resolver, monorepoRoot);
|
||||
|
||||
process.stderr.write(`[scan-codebase] ${files.length} files, ${routes.length} routes, ${scanners.length} scanners\n`);
|
||||
|
||||
const findings = [];
|
||||
for (const scanner of scanners) {
|
||||
try {
|
||||
const applicable = filterApplicable(files, scanner.metadata);
|
||||
// Scanners may be sync or async (large-static-asset does fs.stat walks).
|
||||
const found = await scanner.scan({ files: applicable, rootDir, routes, stack });
|
||||
for (const f of (found ?? [])) {
|
||||
findings.push({
|
||||
...f,
|
||||
route: mapFileToRoute(f.file, routes),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`[scan-codebase] scanner ${scanner.metadata?.id} threw: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
findings.sort((a, b) =>
|
||||
a.file.localeCompare(b.file)
|
||||
|| (a.line ?? 0) - (b.line ?? 0)
|
||||
|| a.pattern.localeCompare(b.pattern)
|
||||
);
|
||||
|
||||
process.stdout.write(JSON.stringify({
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
scannedAt: new Date().toISOString(),
|
||||
rootDir,
|
||||
monorepoRoot: monorepoRoot ?? null,
|
||||
workspacePackages: workspacePackages.map((p) => ({ name: p.name, dir: relative(monorepoRoot ?? rootDir, p.dir) })),
|
||||
stack,
|
||||
routes,
|
||||
findings,
|
||||
scannerMetadata: scanners.map((s) => ({
|
||||
id: s.metadata.id,
|
||||
title: s.metadata.title,
|
||||
severity: s.metadata.severity,
|
||||
billingDimension: s.metadata.billingDimension,
|
||||
trafficIndependent: s.metadata.trafficIndependent,
|
||||
})),
|
||||
}, null, 2) + '\n');
|
||||
|
||||
process.stderr.write(`[scan-codebase] ${findings.length} finding(s)\n`);
|
||||
}
|
||||
|
||||
// Record workspace-package imports per route so the brief allowlists them and
|
||||
// sub-agents can investigate the real source rather than abstaining on a thin
|
||||
// re-export shell. Capped to keep the brief focused (source order ≈ import order,
|
||||
// so the primary view component usually leads).
|
||||
const WORKSPACE_IMPORT_LIMIT_PER_ROUTE = 12;
|
||||
async function enrichRoutesWithWorkspaceImports(routes, scanRootDir, resolver, monorepoRoot) {
|
||||
if (!monorepoRoot) return;
|
||||
for (const r of routes) {
|
||||
if (!r?.file) continue;
|
||||
const abs = join(scanRootDir, r.file);
|
||||
const resolved = await resolveWorkspaceImports(abs, resolver, {
|
||||
pureBarrelDepth: 3,
|
||||
suffixFanoutDepth: 2,
|
||||
perSpecifierCap: 3,
|
||||
});
|
||||
if (resolved.length === 0) continue;
|
||||
// Paths must be relative to the monorepo root so they align between signals + verifier.
|
||||
r.workspaceImports = resolved
|
||||
.slice(0, WORKSPACE_IMPORT_LIMIT_PER_ROUTE)
|
||||
.map((abs) => relative(monorepoRoot, abs));
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFiles(root) {
|
||||
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
const segments = (e.parentPath ?? e.path ?? root).split('/');
|
||||
if (segments.some((s) => SKIP_DIRS.has(s))) continue;
|
||||
if (SKIP_FILE_PATTERNS.some((re) => re.test(e.name))) continue;
|
||||
if (!/\.(tsx?|jsx?|mjs|cjs|html|svelte|astro|vue|json)$/.test(e.name)) continue;
|
||||
|
||||
const full = join(e.parentPath ?? e.path ?? root, e.name);
|
||||
try {
|
||||
const content = await readFile(full, 'utf-8');
|
||||
if (content.length > 500_000) continue;
|
||||
out.push({ path: relative(root, full), content });
|
||||
} catch {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterApplicable(files, meta) {
|
||||
const incl = meta.includeGlobs ?? ['**/*'];
|
||||
return files.filter((f) => incl.some((g) => globMatch(g, f.path)));
|
||||
}
|
||||
|
||||
// Tiny glob → regex. Supports **, *, and {a,b} alternation.
|
||||
function globMatch(pattern, path) {
|
||||
const re = new RegExp(
|
||||
'^' +
|
||||
pattern
|
||||
.replace(/[.+^$()|[\]\\]/g, '\\$&')
|
||||
.replace(/\{([^}]+)\}/g, (_, inner) => '(' + inner.split(',').join('|') + ')')
|
||||
.replace(/\*\*/g, '__GLOBSTAR__')
|
||||
.replace(/\*/g, '[^/]*')
|
||||
.replace(/__GLOBSTAR__/g, '.*')
|
||||
+ '$'
|
||||
);
|
||||
return re.test(path);
|
||||
}
|
||||
|
||||
async function enumerateRoutes(root) {
|
||||
const entries = await readdir(root, { recursive: true, withFileTypes: true });
|
||||
const routes = [];
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
const segments = (e.parentPath ?? e.path ?? root).split('/');
|
||||
if (segments.some((s) => SKIP_DIRS.has(s))) continue;
|
||||
|
||||
const full = join(e.parentPath ?? e.path ?? root, e.name);
|
||||
const rel = relative(root, full);
|
||||
|
||||
// App Router: route groups ((name)), parallel routes (@slot), private folders
|
||||
// (_name), and the top-level page.tsx (no path segment) all need explicit handling.
|
||||
let m = rel.match(/^(?:src\/)?app\/(.*)\/(page|route|layout)\.(tsx?|jsx?)$/);
|
||||
if (!m) {
|
||||
const top = rel.match(/^(?:src\/)?app\/(page|route|layout)\.(tsx?|jsx?)$/);
|
||||
if (top) {
|
||||
routes.push({
|
||||
routePath: '/',
|
||||
file: rel,
|
||||
type: routeEntryType(top[1]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (m) {
|
||||
const stripped = m[1]
|
||||
.split('/')
|
||||
.filter((seg) => !/^\([^)]+\)$/.test(seg) && !/^@/.test(seg) && !/^_/.test(seg))
|
||||
.join('/')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
const routePath = stripped === '' ? '/' : `/${stripped}`;
|
||||
routes.push({
|
||||
routePath,
|
||||
file: rel,
|
||||
type: routeEntryType(m[2]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Astro endpoint filenames commonly include the response extension
|
||||
// (`feed.xml.ts`, `robots.txt.ts`). Handle these before the generic
|
||||
// `src/pages` rule, which otherwise treats them as page components.
|
||||
m = rel.match(/^src\/pages\/(.*\.(?:xml|json|txt|rss|atom|svg|png|jpg|jpeg|webp))\.(tsx?|jsx?|mjs|cjs)$/);
|
||||
if (m) {
|
||||
const name = normalizeRouteFileStem(m[1]);
|
||||
routes.push({
|
||||
routePath: name === '' ? '/' : '/' + name,
|
||||
file: rel,
|
||||
type: 'route',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
m = rel.match(/^(?:src\/)?pages\/(.*)\.(tsx?|jsx?)$/);
|
||||
if (m) {
|
||||
const name = m[1].replace(/\/index$/, '').replace(/^index$/, '');
|
||||
const isApi = /^api\//.test(name);
|
||||
routes.push({
|
||||
routePath: name === '' ? '/' : '/' + name,
|
||||
file: rel,
|
||||
type: isApi ? 'route' : 'page',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nuxt 3/4 pages. Dynamic segments use the same bracket shape as metrics
|
||||
// (`[id]`, `[...slug]`), so keep them intact for route matching.
|
||||
m = rel.match(/^(?:app\/)?pages\/(.*)\.vue$/);
|
||||
if (m) {
|
||||
const name = normalizeRouteFileStem(m[1]);
|
||||
routes.push({
|
||||
routePath: name === '' ? '/' : '/' + name,
|
||||
file: rel,
|
||||
type: 'page',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nuxt server routes: server/api/foo.get.ts -> /api/foo,
|
||||
// server/routes/rss.xml.ts -> /rss.xml.
|
||||
m = rel.match(/^server\/(api|routes)\/(.*)\.(tsx?|jsx?|mjs|cjs)$/);
|
||||
if (m) {
|
||||
const base = m[1] === 'api' ? 'api/' : '';
|
||||
const name = normalizeRouteFileStem(`${base}${m[2]}`);
|
||||
routes.push({
|
||||
routePath: name === '' ? '/' : '/' + name,
|
||||
file: rel,
|
||||
type: 'route',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Astro pages and endpoints. This is limited framework support, but route
|
||||
// mapping still improves reports when Vercel metrics use user-facing paths.
|
||||
m = rel.match(/^src\/pages\/(.*)\.(astro|tsx?|jsx?|mjs|cjs)$/);
|
||||
if (m) {
|
||||
const name = normalizeRouteFileStem(m[1]);
|
||||
routes.push({
|
||||
routePath: name === '' ? '/' : '/' + name,
|
||||
file: rel,
|
||||
type: m[2] === 'astro' ? 'page' : 'route',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// SvelteKit: +page.svelte = page, +page.server.{ts,js} pairs with it (treat
|
||||
// as page), +server.{ts,js} = API route, +layout.* = ancestor layout context.
|
||||
// Route groups (auth) stripped like Next; dynamic segments [slug]/[...rest]/[[opt]] preserved.
|
||||
m = rel.match(/^src\/routes\/(.*)\/\+(page\.svelte|page\.server\.(?:ts|js)|server\.(?:ts|js)|layout\.svelte|layout\.server\.(?:ts|js))$/);
|
||||
if (m || /^src\/routes\/\+(page\.svelte|page\.server\.(?:ts|js)|server\.(?:ts|js)|layout\.svelte|layout\.server\.(?:ts|js))$/.test(rel)) {
|
||||
const fileTypeMatch = rel.match(/\+(page\.svelte|page\.server\.(?:ts|js)|server\.(?:ts|js)|layout\.svelte|layout\.server\.(?:ts|js))$/);
|
||||
const fileType = fileTypeMatch?.[1] ?? '';
|
||||
const segs = (m?.[1] ?? '').split('/').filter(Boolean)
|
||||
.filter((seg) => !/^\([^)]+\)$/.test(seg));
|
||||
const routePath = segs.length === 0 ? '/' : '/' + segs.join('/');
|
||||
const type = fileType.startsWith('server') ? 'route' : fileType.startsWith('layout') ? 'layout' : 'page';
|
||||
// When +page.svelte AND +page.server.ts both exist, +page.svelte wins ownership.
|
||||
const existing = type === 'layout' ? null : routes.find((r) => r.routePath === routePath && r.type !== 'layout');
|
||||
if (existing) {
|
||||
if (fileType === 'page.svelte' && existing.type === 'page') {
|
||||
existing.file = rel;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
routes.push({ routePath, file: rel, type });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return routes.sort((a, b) =>
|
||||
a.routePath.localeCompare(b.routePath)
|
||||
|| routeTypeOrder(a.type) - routeTypeOrder(b.type)
|
||||
|| a.file.localeCompare(b.file)
|
||||
);
|
||||
}
|
||||
|
||||
function routeEntryType(name) {
|
||||
return name === 'route' ? 'route' : name === 'layout' ? 'layout' : 'page';
|
||||
}
|
||||
|
||||
function normalizeRouteFileStem(stem) {
|
||||
return String(stem ?? '')
|
||||
.replace(/\/index$/, '')
|
||||
.replace(/^index$/, '')
|
||||
.replace(/\.(?:get|post|put|patch|delete|options|head)$/, '')
|
||||
.replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function routeTypeOrder(type) {
|
||||
return type === 'page' ? 0 : type === 'route' ? 1 : type === 'layout' ? 2 : 3;
|
||||
}
|
||||
|
||||
function mapFileToRoute(filePath, routes) {
|
||||
const r = routes.find((rt) => rt.file === filePath);
|
||||
return r?.routePath ?? null;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`[scan-codebase] FAILED: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env node
|
||||
// Verify → grade → emit regenPlan. Does NOT spawn sub-agents — the
|
||||
// orchestrator reads regenPlan, re-spawns one sub-agent per targeted candidate
|
||||
// with topFailures injected, then re-runs this script. Thresholds are tuned
|
||||
// below (REGEN_*, QUALITY_FLOOR) — read those constants for the live values.
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import { verifyClaim } from '../lib/verify-claim.mjs';
|
||||
import { extractClaims, summarizeClaimResults } from '../lib/extract-claims.mjs';
|
||||
import { gradeRecommendation, applyQualityFloor } from '../lib/grade-recommendation.mjs';
|
||||
import { deriveProjectFacts } from '../lib/project-facts.mjs';
|
||||
import { resolveRepoRoot } from '../lib/repo-root.mjs';
|
||||
import { applySanitizers } from '../lib/sanitizers/index.mjs';
|
||||
|
||||
const SCHEMA_VERSION = '1.0';
|
||||
const REGEN_PASS_RATE_THRESHOLD = 0.8;
|
||||
// 1/1 failed is as broken as 1/5; below 2 claims is below the noise floor.
|
||||
const REGEN_MIN_CLAIMS = 2;
|
||||
// The Poor/Fair grade boundary — Poor recs erode trust faster than recall helps.
|
||||
const QUALITY_FLOOR = 0.55;
|
||||
|
||||
const log = (...a) => console.error('[verify-and-regen]', ...a);
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
if (!args.recsPath) {
|
||||
console.error('usage: node scripts/verify-and-regen.mjs <recommendations.json> [--signals merged.json] [--repo-root DIR] [--out FILE]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const recs = JSON.parse(await readFile(args.recsPath, 'utf-8'));
|
||||
if (!Array.isArray(recs)) {
|
||||
console.error('[verify-and-regen] FATAL: recommendations.json must be an array of rec objects');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
let framework, version, cacheComponents, knownFindings = [], projectFacts = [], signals = null;
|
||||
if (args.signalsPath) {
|
||||
signals = JSON.parse(await readFile(args.signalsPath, 'utf-8'));
|
||||
const stack = signals.stack ?? signals.codebase?.stack ?? {};
|
||||
framework = stack.framework;
|
||||
version = stack.frameworkVersion;
|
||||
cacheComponents = stack.cacheComponents;
|
||||
knownFindings = (signals.codebase?.findings ?? signals.findings ?? [])
|
||||
.filter((f) => f.file && (f.line != null))
|
||||
.map((f) => ({ file: f.file, line: f.line }));
|
||||
projectFacts = deriveProjectFacts(signals);
|
||||
if (projectFacts.length > 0) {
|
||||
log(`project facts in play: ${projectFacts.map((f) => f.id).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Repo-root priority: (1) signals.project.rootDirectory from Vercel API
|
||||
// (authoritative — returns "apps/<name>" so cwd can be back-mapped without
|
||||
// filesystem probing), (2) supplied --repo-root, (3) walk-up from cwd.
|
||||
const rootResult = await resolveRepoRoot(recs, args.repoRoot, process.cwd(), signals);
|
||||
const repoRoot = rootResult.root;
|
||||
if (rootResult.source === 'api') {
|
||||
log(`repo-root from Vercel API: '${repoRoot}' (rootDirectory='${rootResult.apiOffset}')`);
|
||||
} else if (rootResult.source === 'auto-detected') {
|
||||
log(`repo-root auto-detected: '${repoRoot}' (probe: ${rootResult.probe})`);
|
||||
} else if (rootResult.source === 'corrected') {
|
||||
log(`repo-root auto-corrected: '${args.repoRoot}' → '${repoRoot}' (sub-agent paths resolve there)`);
|
||||
}
|
||||
log(`verifying ${recs.length} rec(s) — framework=${framework ?? '?'}@${version ?? '?'} repoRoot=${repoRoot}`);
|
||||
|
||||
// knownFindings MUST combine scanner findings + sub-agent's verified
|
||||
// findingRefs — scanner-only grounding would miss every metric-gate rec.
|
||||
// Abstentions are first-class outputs ({abstain:true, candidateRef, reason})
|
||||
// and MUST NOT be graded; the abstention IS the answer.
|
||||
const recsGraded = [];
|
||||
const abstentions = [];
|
||||
const observations = [];
|
||||
const sanitizerDropped = [];
|
||||
for (let i = 0; i < recs.length; i++) {
|
||||
const rec = recs[i];
|
||||
|
||||
if (rec?.abstain === true) {
|
||||
abstentions.push({
|
||||
index: i,
|
||||
candidateRef: rec.candidateRef ?? null,
|
||||
reason: rec.reason ?? '(no reason recorded)',
|
||||
});
|
||||
// Observation: real non-perf signal worth surfacing (regression, error storm).
|
||||
if (rec.observation && typeof rec.observation === 'object' && rec.observation.summary) {
|
||||
observations.push({
|
||||
index: i,
|
||||
candidateRef: rec.candidateRef ?? null,
|
||||
summary: String(rec.observation.summary),
|
||||
evidence: rec.observation.evidence ?? null,
|
||||
suggestedAction: rec.observation.suggestedAction ?? null,
|
||||
kind: rec.observation.kind ?? 'other',
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseClaimCtx = {
|
||||
framework,
|
||||
version,
|
||||
repoRoot,
|
||||
projectFacts,
|
||||
projectRootDirectory: signals?.project?.rootDirectory ?? null,
|
||||
cacheComponents,
|
||||
signals,
|
||||
};
|
||||
const initialClaims = extractClaims(rec, baseClaimCtx);
|
||||
const initialVerifyResults = await Promise.all(initialClaims.map((c) => verifyClaim(c)));
|
||||
const initialClaimsWithResults = initialVerifyResults.map((r, j) => ({
|
||||
...r,
|
||||
type: initialClaims[j]?.type,
|
||||
claimType: initialClaims[j]?.type,
|
||||
claim: initialClaims[j],
|
||||
}));
|
||||
const sanitizerResult = await applySanitizers(rec, {
|
||||
framework,
|
||||
version,
|
||||
signals,
|
||||
verifyResults: initialClaimsWithResults,
|
||||
});
|
||||
if (!sanitizerResult.kept) {
|
||||
sanitizerDropped.push({
|
||||
index: i,
|
||||
candidateRef: rec.candidateRef ?? null,
|
||||
what: rec.what ?? null,
|
||||
reason: sanitizerResult.dropReason ?? 'automated-check',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const sanitizedRec = sanitizerResult.rec;
|
||||
const claims = extractClaims(sanitizedRec, baseClaimCtx);
|
||||
const verifyResults = await Promise.all(claims.map((c) => verifyClaim(c)));
|
||||
const verification = summarizeClaimResults(verifyResults);
|
||||
|
||||
// A findingRef whose file_exists claim verified counts as grounding evidence.
|
||||
const verifiedRefs = [];
|
||||
for (let j = 0; j < claims.length; j++) {
|
||||
const c = claims[j];
|
||||
const r = verifyResults[j];
|
||||
if (r?.disposition !== 'verified') continue;
|
||||
if (c.sourceField === 'findingRefs' && c.type === 'file_exists') {
|
||||
const ref = (rec.findingRefs ?? []).find((x) => String(x).startsWith(c.file + ':'));
|
||||
if (ref) {
|
||||
const m = String(ref).match(/^(.+?):(\d+)$/);
|
||||
if (m) verifiedRefs.push({ file: m[1], line: Number(m[2]) });
|
||||
}
|
||||
}
|
||||
}
|
||||
const recKnownFindings = [...knownFindings, ...verifiedRefs];
|
||||
const quality = gradeRecommendation(sanitizedRec, { knownFindings: recKnownFindings });
|
||||
|
||||
recsGraded.push({
|
||||
index: i,
|
||||
rec: { ...sanitizedRec, verification, verifyResults, quality },
|
||||
claims,
|
||||
verifyResults,
|
||||
verification,
|
||||
quality,
|
||||
});
|
||||
}
|
||||
|
||||
// Project-config contradictions are a HARD trigger: a "turn on Fluid" rec on
|
||||
// a project where Fluid is already on passes 8/9 claims but is the wrong rec.
|
||||
// passRate alone won't catch this.
|
||||
const regenPlan = [];
|
||||
for (const g of recsGraded) {
|
||||
const { passRate, verifiable } = g.verification;
|
||||
const claimsWithResults = g.verifyResults.map((r, j) => ({ ...r, claim: g.claims[j] }));
|
||||
const contradictions = claimsWithResults.filter(
|
||||
(r) => r.disposition === 'failed' && r.claim?.type === 'does_not_contradict_project_config'
|
||||
);
|
||||
const triggeredByPassRate = verifiable >= REGEN_MIN_CLAIMS && passRate < REGEN_PASS_RATE_THRESHOLD;
|
||||
const cacheSafetyFailures = claimsWithResults.filter(
|
||||
(r) => r.disposition === 'failed' && (
|
||||
r.claim?.type === 'cache_vary_matches_dynamic_inputs' ||
|
||||
r.claim?.type === 'cache_vary_cardinality_safe'
|
||||
)
|
||||
);
|
||||
const semanticSafetyFailures = claimsWithResults.filter(
|
||||
(r) => r.disposition === 'failed' && (
|
||||
r.claim?.type === 'next_cached_not_found_causal_support' ||
|
||||
r.claim?.type === 'next_stable_cache_api_for_version' ||
|
||||
r.claim?.type === 'next_runtime_cache_api_for_version' ||
|
||||
r.claim?.type === 'next_cache_life_single_execution' ||
|
||||
r.claim?.type === 'next_cache_lifetime_freshness_supported' ||
|
||||
r.claim?.type === 'next_cache_components_route_chain_file' ||
|
||||
r.claim?.type === 'next_cache_life_cdn_header_semantics' ||
|
||||
r.claim?.type === 'image_response_headers_citation' ||
|
||||
r.claim?.type === 'next_image_priority_api_for_version' ||
|
||||
r.claim?.type === 'next_cache_components_route_segment_config' ||
|
||||
r.claim?.type === 'next_route_revalidate_static_prereq' ||
|
||||
r.claim?.type === 'next_cache_tag_invalidation_supported' ||
|
||||
r.claim?.type === 'cache_rec_not_error_dominated_or_acknowledged' ||
|
||||
r.claim?.type === 'cache_control_header_syntax' ||
|
||||
r.claim?.type === 'cache_control_headers_citation' ||
|
||||
r.claim?.type === 'cache_404_long_ttl_safety' ||
|
||||
r.claim?.type === 'route_error_not_found_status_and_scope' ||
|
||||
r.claim?.type === 'immutable_dynamic_route_safety' ||
|
||||
r.claim?.type === 'auth_guard_parallelization_safety' ||
|
||||
r.claim?.type === 'parallelization_impact_not_overclaimed' ||
|
||||
r.claim?.type === 'parallelization_not_cpu_bound_work' ||
|
||||
r.claim?.type === 'runtime_error_cause_supported' ||
|
||||
r.claim?.type === 'vercel_ignore_command_project_state'
|
||||
)
|
||||
);
|
||||
const triggeredByContradiction = contradictions.length > 0;
|
||||
const triggeredByCacheSafety = cacheSafetyFailures.length > 0;
|
||||
const triggeredBySemanticSafety = semanticSafetyFailures.length > 0;
|
||||
if (!triggeredByPassRate && !triggeredByContradiction && !triggeredByCacheSafety && !triggeredBySemanticSafety) continue;
|
||||
|
||||
const failures = claimsWithResults
|
||||
.filter((r) => r.disposition === 'failed')
|
||||
.slice(0, 5);
|
||||
regenPlan.push({
|
||||
index: g.index,
|
||||
candidateRef: g.rec.candidateRef ?? null,
|
||||
what: g.rec.what ?? null,
|
||||
verifiableClaimCount: verifiable,
|
||||
passRate,
|
||||
regenTrigger: triggeredByContradiction
|
||||
? 'project_config_contradiction'
|
||||
: triggeredByCacheSafety
|
||||
? 'cache_vary_safety'
|
||||
: triggeredBySemanticSafety
|
||||
? 'semantic_safety'
|
||||
: 'pass_rate_below_threshold',
|
||||
topFailures: failures.map((f) => ({
|
||||
claimType: f.claim?.type,
|
||||
field: f.claim?.sourceField,
|
||||
url: f.claim?.url,
|
||||
file: f.claim?.file,
|
||||
pattern: f.claim?.pattern,
|
||||
reason: f.reason,
|
||||
})),
|
||||
regenBriefHint: triggeredByContradiction
|
||||
? 'Sub-agent recommended toggling on a project setting that is already enabled. Re-spawn with the project-config Strengths block highlighted; the rec must drop the contradictory step and keep only the actionable parts.'
|
||||
: triggeredByCacheSafety
|
||||
? 'Sub-agent recommended CDN caching with unsafe or missing Vary behavior. Re-spawn with the cache safety failure highlighted; the rec must use a low-cardinality Vary header that matches the dynamic inputs, or abstain.'
|
||||
: triggeredBySemanticSafety
|
||||
? 'Sub-agent made a framework-semantic claim that failed deterministic checks. Re-spawn with the failure highlighted; the rec must either add version-correct code/citations/runtime evidence or abstain.'
|
||||
: 'Re-spawn the sub-agent with this rec\'s topFailures injected as feedback. Re-emit the rec only if regenPassRate >= originalPassRate AND citation count not gutted.',
|
||||
});
|
||||
}
|
||||
|
||||
const qualityCheck = applyQualityFloor(recsGraded.map((g) => g.rec), QUALITY_FLOOR);
|
||||
const hardRegenIndexes = new Set(regenPlan.map((p) => p.index));
|
||||
const qualityDroppedIndexes = new Set(
|
||||
qualityCheck.dropped
|
||||
.map((d) => recsGraded.findIndex((g) => g.rec === d.rec))
|
||||
.filter((i) => i >= 0)
|
||||
);
|
||||
const needsReviewIndexes = new Set(
|
||||
recsGraded
|
||||
.filter((g) => g.rec.needsReview === true)
|
||||
.map((g) => g.index)
|
||||
);
|
||||
const verifiedRecommendations = recsGraded
|
||||
.filter((g) => !hardRegenIndexes.has(g.index) && !qualityDroppedIndexes.has(g.index) && !needsReviewIndexes.has(g.index))
|
||||
.map((g) => g.rec);
|
||||
const withheldRecommendations = recsGraded
|
||||
.filter((g) => hardRegenIndexes.has(g.index) || qualityDroppedIndexes.has(g.index) || needsReviewIndexes.has(g.index))
|
||||
.map((g) => ({
|
||||
index: g.index,
|
||||
candidateRef: g.rec.candidateRef ?? null,
|
||||
what: g.rec.what ?? null,
|
||||
reason: hardRegenIndexes.has(g.index)
|
||||
? (regenPlan.find((p) => p.index === g.index)?.regenTrigger ?? 'verification')
|
||||
: qualityDroppedIndexes.has(g.index)
|
||||
? 'quality_floor'
|
||||
: 'needs_review',
|
||||
}));
|
||||
|
||||
const summary = {
|
||||
totalRecs: recs.length,
|
||||
abstentions: abstentions.length,
|
||||
observations: observations.length,
|
||||
sanitizerDropped: sanitizerDropped.length,
|
||||
needsRegen: regenPlan.length,
|
||||
qualityDropped: qualityCheck.dropped.length,
|
||||
needsReview: needsReviewIndexes.size,
|
||||
verifiedRecommendations: verifiedRecommendations.length,
|
||||
withheldRecommendations: withheldRecommendations.length,
|
||||
averagePassRate: recsGraded.length > 0
|
||||
? round4(recsGraded.reduce((s, g) => s + g.verification.passRate, 0) / recsGraded.length)
|
||||
: null,
|
||||
averageQuality: recsGraded.length > 0
|
||||
? round4(recsGraded.reduce((s, g) => s + g.quality.overall, 0) / recsGraded.length)
|
||||
: null,
|
||||
};
|
||||
|
||||
const output = {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
summary,
|
||||
recsGraded: recsGraded.map((g) => g.rec),
|
||||
verifiedRecommendations,
|
||||
renderableRecommendations: verifiedRecommendations,
|
||||
withheldRecommendations,
|
||||
abstentions,
|
||||
observations,
|
||||
sanitizerDropped,
|
||||
regenPlan,
|
||||
qualityDropped: qualityCheck.dropped.map((d) => ({
|
||||
index: recsGraded.findIndex((g) => g.rec === d.rec),
|
||||
candidateRef: d.rec.candidateRef ?? null,
|
||||
quality: d.rec.quality,
|
||||
reason: d.reason,
|
||||
})),
|
||||
};
|
||||
|
||||
const serialized = JSON.stringify(output, null, 2) + '\n';
|
||||
if (args.outPath) {
|
||||
await mkdir(dirname(args.outPath), { recursive: true });
|
||||
await writeFile(args.outPath, serialized, 'utf-8');
|
||||
log(`wrote ${serialized.length}B → ${args.outPath}`);
|
||||
} else {
|
||||
process.stdout.write(serialized);
|
||||
}
|
||||
log(`done: ${summary.totalRecs} records checked; ${summary.verifiedRecommendations} ready, ${summary.withheldRecommendations} held back, ${summary.abstentions} found no supported change, ${summary.sanitizerDropped} dropped by safety checks`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = { positional: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--signals') out.signalsPath = argv[++i];
|
||||
else if (a.startsWith('--signals=')) out.signalsPath = a.slice('--signals='.length);
|
||||
else if (a === '--repo-root') out.repoRoot = argv[++i];
|
||||
else if (a.startsWith('--repo-root=')) out.repoRoot = a.slice('--repo-root='.length);
|
||||
else if (a === '--out') out.outPath = resolve(argv[++i]);
|
||||
else if (a.startsWith('--out=')) out.outPath = resolve(a.slice('--out='.length));
|
||||
else out.positional.push(a);
|
||||
}
|
||||
out.recsPath = out.positional[0];
|
||||
return out;
|
||||
}
|
||||
|
||||
function round4(n) { return Math.round(n * 10000) / 10000; }
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[verify-and-regen] FAILED:', err.message);
|
||||
console.error(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
// CLI shell around lib/verify-claim.mjs. argv[2] = JSON claim, stdout = result.
|
||||
// Claim `type` enum: pattern_count | pattern_exists | pattern_absent | file_exists |
|
||||
// code_snippet | repo_count | citation_in_library | citation_applies_to_version.
|
||||
|
||||
import { verifyClaim } from '../lib/verify-claim.mjs';
|
||||
|
||||
const SCHEMA_VERSION = '1.0';
|
||||
|
||||
async function main() {
|
||||
const claim = JSON.parse(process.argv[2] || '{}');
|
||||
const result = await verifyClaim(claim);
|
||||
process.stdout.write(JSON.stringify({ schemaVersion: SCHEMA_VERSION, ...result }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`[verify-finding] FAILED: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user