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
12 changes: 11 additions & 1 deletion src/map/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { relative } from 'node:path';
import type { SiteInputMap, Endpoint, TsModule } from './types.js';
import { guessScriptKind } from './ast.js';
import { buildModuleBindings } from './bindings.js';
import { collectSources, detectFramework, hasEntrySignal, type WalkStats } from './sources.js';
import { collectSources, detectDeploymentShapes, detectFramework, hasEntrySignal, type WalkStats } from './sources.js';
import { functionNameFromPath, routeFromFilePath } from './routes.js';
import { collectLocalSinks } from './sinks.js';
import { createModuleGraph } from './module-graph.js';
Expand Down Expand Up @@ -232,9 +232,19 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac
}
} catch { /* not available */ }

const deploymentShapes = detectDeploymentShapes(cwd, { boundary, followOutside: options.followSymlinks });
// Emitted UNCONDITIONALLY, and the empty case is the one that needs it most: an empty list is the only
// state a consumer could read as "this app has no server", and a JSON reader sees none of the type
// documentation that says otherwise. Attaching the caveat only when something was found put the warning
// 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.`);

return {
version: 3,
framework: detectFramework(cwd),
deploymentShapes,
endpoints,
imports: importList,
apiInvocations: invocationList,
Expand Down
143 changes: 143 additions & 0 deletions src/map/sources.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
import { join, relative, isAbsolute } from 'node:path';
import { ROUTE_CALL_RE } from './routes.js';
import type { DeploymentEvidence, DeploymentShape } from './types.js';

// Cheap textual pre-filter so we only parse files that could contain an entry point. Derived from the
// same list as the AST recognizer (see ROUTE_REGISTER_NAMES).
Expand Down Expand Up @@ -112,3 +113,145 @@ export function isInside(candidate: string, boundary: string): boolean {
const rel = relative(boundary, candidate);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}

// --- deployment shapes ------------------------------------------------------
// What the PROJECT says about where it runs, as opposed to what its source says it does.
//
// The two answers come apart in the direction that matters. A project can hold a serverless function
// this extractor cannot parse — an unfamiliar handler signature, a runtime it does not model — and the
// endpoint walk then reports nothing, which is indistinguishable from an app that has no server at all.
// A consumer reading only `endpoints: []` would call that app static and tell its owner there is nothing
// to protect.
//
// So these are POSITIVE artifacts: a config file or a platform directory that exists. Each finding names
// the thing that proved it, because a classification a consumer cannot explain is one it should not act
// on — and the absence of every shape below is still not evidence of absence, only of "we found none".
//
// 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/`)
//
// `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.
//
// `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[] }> = [
// 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'] },
// 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'] },
// 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.
{ shape: 'root-functions-directory', evidence: 'layout', dirs: ['functions'] },
// The bare-root Vercel convention: `api/handler.ts` with no framework router. Next owns `pages/api`
// and `app/api` instead, which the endpoint walk already recognizes, so this is reported as its own
// shape rather than folded into `vercel`.
{ shape: 'root-api-directory', evidence: 'layout', dirs: ['api'] },
];

export interface DeploymentScanOptions {
/** Project boundary (a real path). Candidates resolving outside it are refused. */
boundary?: string;
/** Follow artifacts that resolve outside the project (off by default, like the source walk). */
followOutside?: boolean;
}

/**
* Deployment artifacts present in the project, each with the file or directory that evidenced it.
*
* Cheap by construction: a handful of `statSync` calls at known paths, no walking. Never throws — an
* unreadable project yields an empty list, which is a "found none" and must not be read as "has none".
*
* Symlinks are resolved and refused when they leave the project, the same rule the source walk applies. A
* symlinked `api/` pointing at a sibling workspace would otherwise become THIS project's deployment
* evidence — the analysis would describe a runtime that belongs to different code.
*/
export function detectDeploymentShapes(cwd: string, opts: DeploymentScanOptions = {}): DeploymentShape[] {
let boundary = opts.boundary ?? cwd;
try { boundary = realpathSync(boundary); } catch { /* use as given */ }

const inProject = (path: string): boolean => {
if (opts.followOutside) return true;
try {
return isInside(realpathSync(path), boundary);
} catch {
return false; // unresolvable is not in-project, and not evidence
}
};

const found: DeploymentShape[] = [];

for (const candidate of DEPLOYMENT_SHAPES) {
for (const file of candidate.files ?? []) {
const full = join(cwd, file);
try {
if (statSync(full).isFile() && inProject(full)) {
found.push({ shape: candidate.shape, source: file, evidence: candidate.evidence });
break; // one spelling is enough; the shape is the claim, not the filename
}
} catch { /* not this one */ }
}

for (const dir of candidate.dirs ?? []) {
const full = join(cwd, dir);
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 */ }
}
}

return found;
}

/**
* Whether a directory holds at least one source file, one level down included.
*
* No boundary check here, and deliberately not: `readdirSync(withFileTypes)` classifies a symlink as
* neither a file nor a directory, so a linked entry can never satisfy either branch and cannot smuggle
* outside code into this test. Every entry that reaches a `return true` is a real file at a real path
* under `dir`, which the caller has already confirmed is in-project.
*
* (A first version did check the boundary at each hop. It was unreachable — verified by removing the
* top-level refusal, which failed the escaping-directory tests while the nested one stayed green.)
*
* The accepted cost is a legitimate in-project symlink inside a provider directory not counting as
* source. That errs toward reporting no shape, which the map already states is not evidence of absence.
*/
function holdsSourceFile(dir: string): boolean {
try {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isFile() && isSourceFile(entry.name)) return true;
// One level deeper covers the per-function layout (`netlify/functions/hello/index.ts`) without
// turning this into a walk.
if (entry.isDirectory()) {
try {
for (const nested of readdirSync(join(dir, entry.name), { withFileTypes: true })) {
if (nested.isFile() && isSourceFile(nested.name)) return true;
}
} catch { /* unreadable subdirectory */ }
}
}
} catch { /* unreadable */ }

return false;
}
42 changes: 42 additions & 0 deletions src/map/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,34 @@ export interface Flow {
line?: number;
}

/**
* How strong a deployment finding is. Not all artifacts prove the same thing:
*
* `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 hold functions (`api/`, `functions/`)
*/
export type DeploymentEvidence = 'config' | 'provider-directory' | 'layout';

export interface DeploymentShape {
/** Which shape was recognized, e.g. `netlify-functions`. */
shape: string;
/** The artifact that proved it, repo-relative, so a consumer can show its evidence. */
source: string;
/**
* How strong the finding is. Not all artifacts prove the same thing:
*
* `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 hold functions (`api/`, `functions/`)
*
* `layout` is deliberately weaker: `api/client.ts` is a normal front-end folder and `api/handler.ts` is a
* platform function, and the directory name is the same either way. A consumer may use `layout` to stay
* undecided; it must not conclude a server runtime from `layout` alone.
*/
evidence: DeploymentEvidence;
}

export interface Coverage {
/** Adapter that produced the map. */
adapter: string;
Expand Down Expand Up @@ -391,6 +419,20 @@ export interface SiteInputMap {
version: 3;
/** e.g. "tanstack-start". */
framework: string;
/**
* Deployment artifacts the project itself declares — a `vercel.json`, a `netlify/functions` directory,
* a `wrangler.toml` — each with the file or directory that evidenced it.
*
* Positive evidence only, and it exists because the negative form is dangerous: a serverless function
* this extractor cannot parse produces no endpoint, which is indistinguishable from an app that has no
* server at all. A consumer reading only an empty `endpoints` list would call such an app static and
* tell its owner there is nothing to protect. An empty list here means "no known deployment artifact
* was found", never "this app has no server-side runtime" — that claim needs deployment or build
* attestation, which source analysis cannot supply.
*
* Additive, so still version 3: a v3 reader that ignores it keeps behaving correctly.
*/
deploymentShapes?: DeploymentShape[];
endpoints: Endpoint[];
coverage: Coverage;
/**
Expand Down
Loading
Loading