Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SiteInputMap, Endpoint, TsModule } from './types.js';
import { guessScriptKind } from './ast.js';
import { buildModuleBindings } from './bindings.js';
import { collectSources, detectDeploymentShapes, detectFramework, hasEntrySignal, type WalkStats } from './sources.js';
import { classifyServerSurface, surfaceNote } from './surface.js';
import { functionNameFromPath, routeFromFilePath } from './routes.js';
import { collectLocalSinks } from './sinks.js';
import { createModuleGraph } from './module-graph.js';
Expand Down Expand Up @@ -239,12 +240,19 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
// everywhere except the case it warns about.
notes.push(deploymentShapes.length === 0
? '`deploymentShapes` is EMPTY: no deployment artifact was recognized. That is not evidence the app has no server-side runtime — a serverless handler this analysis cannot parse produces no endpoint and looks identical to an app that has none, and entry-point recognition has no completeness flag (coverage.importsComplete covers the import inventory only). A definitive answer needs deployment or build attestation, which source analysis cannot supply.'
: `\`deploymentShapes\` records ${deploymentShapes.length} deployment artifact(s) the project declares (${deploymentShapes.map((s) => s.shape).join(', ')}). POSITIVE EVIDENCE ONLY, and findings differ in strength: \`config\` and \`provider-directory\` show a deployment, while \`layout\` (a root \`api/\` or \`functions/\` folder) is an ordinary application folder that may hold no function at all — it must not on its own be read as a server runtime.`);
: `\`deploymentShapes\` records ${deploymentShapes.length} deployment artifact(s) the project declares (${deploymentShapes.map((s) => s.shape).join(', ')}). POSITIVE EVIDENCE ONLY, and findings differ in strength: only \`runtime-entry\` (a worker entry, or a provider function directory with source) shows something that serves. \`deployment-config\` means the project deploys to a platform — a static site on Netlify has a \`netlify.toml\` — and \`layout\` (a root \`api/\` or \`functions/\` folder) is an ordinary application folder. Neither may on its own be read as a server runtime.`);

// Classified from what was positively found: the endpoints above, the deployment artifacts, and the
// manifest. Emitted with its evidence and its own caveat, in every state — the `unknown` and static
// states are the ones a consumer could over-read, so neither travels without the sentence that bounds it.
const serverSurface = classifyServerSurface(cwd, endpoints.length, deploymentShapes, ts);
notes.push(surfaceNote(serverSurface));

return {
version: 3,
framework: detectFramework(cwd),
deploymentShapes,
serverSurface,
endpoints,
imports: importList,
apiInvocations: invocationList,
Expand Down
99 changes: 76 additions & 23 deletions src/map/sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,28 +130,50 @@ export function isInside(candidate: string, boundary: string): boolean {
// Findings are not equally strong, and the difference is carried in the data rather than left for a
// consumer to rediscover:
//
// config the project DECLARES a deployment (`vercel.json`, `wrangler.toml`, `_worker.js`)
// provider-directory a provider-specific function directory holding real source
// layout an ordinary application folder that MIGHT be functions (`api/`, `functions/`)
// runtime-entry code that SERVES — a worker entry, or a provider function directory with source
// deployment-config the project deploys to a platform, which says nothing about anything serving
// layout an ordinary application folder that MIGHT be functions (`api/`, `functions/`)
//
// The `deployment-config` line is the one worth being careful about: a static site on Netlify has a
// `netlify.toml`, a static Vercel project has a `vercel.json`, and a Pages project deploying only assets
// has a `wrangler.toml`. Reading any of those as a server runtime would classify a large share of purely
// static apps as having one — so they establish "deploys somewhere", nothing more.
//
// `layout` exists because `api/client.ts` is a perfectly normal front-end folder and `api/handler.ts` is a
// Vercel function, and from the outside they are the same directory name. Treating that as proof of a
// server runtime would classify a pile of client-only apps as having one. A classifier may use `layout` to
// stay UNDECIDED; it must not use it alone to conclude a runtime exists.
// Vercel function, and from the outside they are the same directory name.
//
// A classifier may use `deployment-config` and `layout` to stay UNDECIDED; neither may conclude a runtime.
//
// `DeploymentEvidence` and `DeploymentShape` are imported from `types.ts` rather than restated: they are the
// document's contract, and two structural copies of a vocabulary is how the two drift apart later.
const DEPLOYMENT_SHAPES: Array<{ shape: string; evidence: DeploymentEvidence; files?: string[]; dirs?: string[] }> = [
interface ShapeCandidate {
shape: string;
evidence: DeploymentEvidence;
files?: string[];
dirs?: string[];
/**
* Require the per-function directory layout (`<name>/index.ts`) rather than any source underneath.
*
* Supabase needs this: the convention puts shared code in `supabase/functions/_shared/*.ts`, which the
* platform does not deploy and which exists in projects with no deployed function at all. Counting it
* would report a runtime for a project that serves nothing — and this signal is the one the product
* messaging leans on, so a false positive here is a wrong statement to a customer.
*/
requiresFunctionEntry?: boolean;
}

const DEPLOYMENT_SHAPES: ShapeCandidate[] = [
// Config first: these are declarations by the project itself, and they survive a build output being
// absent (a fresh clone has no `.vercel`/`.wrangler` directory).
{ shape: 'vercel', evidence: 'config', files: ['vercel.json'] },
{ shape: 'netlify', evidence: 'config', files: ['netlify.toml'] },
{ shape: 'vercel', evidence: 'deployment-config', files: ['vercel.json'] },
{ shape: 'netlify', evidence: 'deployment-config', files: ['netlify.toml'] },
// Wrangler names a Workers/Pages deployment. `.jsonc` and `.json` are both current spellings.
{ shape: 'cloudflare-workers', evidence: 'config', files: ['wrangler.toml', 'wrangler.jsonc', 'wrangler.json'] },
// Pages advanced mode: a single worker entry at the project root takes over routing entirely.
{ shape: 'cloudflare-pages-advanced', evidence: 'config', files: ['_worker.js', '_worker.ts'] },
{ shape: 'netlify-functions', evidence: 'provider-directory', dirs: ['netlify/functions', 'netlify/edge-functions'] },
{ shape: 'supabase-functions', evidence: 'provider-directory', dirs: ['supabase/functions'] },
{ shape: 'cloudflare-workers', evidence: 'deployment-config', files: ['wrangler.toml', 'wrangler.jsonc', 'wrangler.json'] },
// Pages advanced mode: a single worker entry at the project root takes over routing entirely. Unlike a
// wrangler config, this file IS the server — it is a runtime entry, not a deployment declaration.
{ shape: 'cloudflare-pages-advanced', evidence: 'runtime-entry', files: ['_worker.js', '_worker.ts'] },
{ shape: 'netlify-functions', evidence: 'runtime-entry', dirs: ['netlify/functions', 'netlify/edge-functions'] },
{ shape: 'supabase-functions', evidence: 'runtime-entry', dirs: ['supabase/functions'], requiresFunctionEntry: true },
// Ambiguous by nature and reported as one shape: a root `functions/` directory is Cloudflare Pages
// Functions, Firebase functions, or a Deno layout depending on the platform, and nothing inside the
// repository always distinguishes them. Naming it honestly is better than guessing a provider.
Expand Down Expand Up @@ -207,22 +229,53 @@ export function detectDeploymentShapes(cwd: string, opts: DeploymentScanOptions

for (const dir of candidate.dirs ?? []) {
const full = join(cwd, dir);

// Containment is settled BEFORE anything reads the directory. `statSync` follows symlinks, so a
// linked `api/` reports as a directory here — and inspecting its contents first would mean this
// analysis had already read a tree outside the project, whatever it then did with the result. The
// guarantee is "we do not look", not "we look and discard".
try {
// A directory with no source file in it is scaffolding, not a deployment: an empty `api/`
// would otherwise make every project that once considered serverless look like it ships it.
// `statSync` FOLLOWS symlinks, which is what makes the boundary check here load-bearing: a
// linked `api/` reports as a directory and would otherwise be this project's evidence.
if (statSync(full).isDirectory() && inProject(full) && holdsSourceFile(full)) {
found.push({ shape: candidate.shape, source: dir, evidence: candidate.evidence });
break;
}
} catch { /* not this one */ }
if (!statSync(full).isDirectory() || !inProject(full)) continue;
} catch {
continue; // missing, or unresolvable — either way not evidence
}

// A directory with no source file in it is scaffolding, not a deployment: an empty `api/` would
// otherwise make every project that once considered serverless look like it ships it.
if (candidate.requiresFunctionEntry ? holdsFunctionEntry(full) : holdsSourceFile(full)) {
found.push({ shape: candidate.shape, source: dir, evidence: candidate.evidence });
break;
}
}
}

return found;
}

/**
* Whether a directory holds at least one deployable FUNCTION, in the per-function layout: a child directory
* whose name does not start with `_`, containing an `index.*` source file.
*
* Only ever called for a directory the caller has already confirmed is inside the project.
*
* The underscore rule is the platform's own: Supabase treats `supabase/functions/_shared/` as shared code
* and does not deploy it, so a project can have that directory full of TypeScript and serve nothing.
*/
function holdsFunctionEntry(dir: string): boolean {
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
try {
for (const nested of readdirSync(join(dir, entry.name), { withFileTypes: true })) {
if (nested.isFile() && /^index\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(nested.name)) return true;
}
} catch { /* unreadable candidate */ }
}
} catch { /* unreadable */ }

return false;
}

/**
* Whether a directory holds at least one source file, one level down included.
*
Expand Down
Loading
Loading