diff --git a/src/map/extract.ts b/src/map/extract.ts index f902e47..017ecaa 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -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'; @@ -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, diff --git a/src/map/sources.ts b/src/map/sources.ts index 9cf60c6..471d249 100644 --- a/src/map/sources.ts +++ b/src/map/sources.ts @@ -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). @@ -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; +} diff --git a/src/map/types.ts b/src/map/types.ts index 1f0b055..a973ff8 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -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; @@ -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; /** diff --git a/tests/map/deployment-shapes.test.ts b/tests/map/deployment-shapes.test.ts new file mode 100644 index 0000000..d895843 --- /dev/null +++ b/tests/map/deployment-shapes.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { detectDeploymentShapes } from '../../src/map/sources.js'; +import { buildInputMap } from '../../src/map/index.js'; + +// What the PROJECT declares about where it runs, as distinct from what its source says it does. +// +// The reason this exists is a negative nobody can check: a serverless handler the extractor cannot parse +// produces no endpoint, and an empty endpoint list is indistinguishable from an app that has no server at +// all. A consumer that classified apps on that basis would tell the owner of an unparsed Netlify function +// that there is nothing to protect. So this layer reports artifacts that are PRESENT, and each finding +// names the file or directory that proved it — a classification a consumer cannot explain is one it should +// not act on. +const dirs: string[] = []; +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +const project = (files: Record): string => { + const dir = mkdtempSync(join(tmpdir(), 'ps-deploy-')); + dirs.push(dir); + for (const [rel, body] of Object.entries(files)) { + const path = join(dir, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body); + } + return dir; +}; +const shapesOf = (files: Record) => detectDeploymentShapes(project(files)).map((s) => s.shape); + +describe('a declared deployment artifact is recognized, whatever the source analysis makes of it', () => { + it('recognizes each platform from its own config file', () => { + expect(shapesOf({ 'vercel.json': '{}' })).toContain('vercel'); + expect(shapesOf({ 'netlify.toml': '[build]' })).toContain('netlify'); + expect(shapesOf({ 'wrangler.toml': 'name = "app"' })).toContain('cloudflare-workers'); + // Both current spellings, because a project using the newer one is not less deployed. + expect(shapesOf({ 'wrangler.jsonc': '{}' })).toContain('cloudflare-workers'); + expect(shapesOf({ 'wrangler.json': '{}' })).toContain('cloudflare-workers'); + }); + + it('recognizes the function directories, including a per-function layout', () => { + expect(shapesOf({ 'netlify/functions/hello.ts': 'export default () => {}' })).toContain('netlify-functions'); + // `netlify/functions/hello/index.ts` — one level deeper, which a top-level-only check would miss. + expect(shapesOf({ 'netlify/functions/hello/index.ts': 'export default () => {}' })).toContain('netlify-functions'); + expect(shapesOf({ 'netlify/edge-functions/geo.ts': 'export default () => {}' })).toContain('netlify-functions'); + expect(shapesOf({ 'supabase/functions/notify/index.ts': 'Deno.serve(() => new Response("ok"))' })).toContain('supabase-functions'); + }); + + it('recognizes a Pages advanced-mode worker at the project root', () => { + // `_worker.js` takes over routing for the whole deployment, so its presence is a server runtime even + // when every other file in the project is static. + expect(shapesOf({ '_worker.js': 'export default { fetch: () => new Response("ok") }' })) + .toContain('cloudflare-pages-advanced'); + }); + + it('names an ambiguous directory for what it is rather than guessing a provider', () => { + // A root `functions/` directory is Cloudflare Pages Functions, Firebase, or a Deno layout depending on + // the platform, and nothing inside the repository reliably says which. Reporting the shape honestly + // beats attributing it to a provider that may not be involved. + expect(shapesOf({ 'functions/hello.js': 'export const onRequest = () => {}' })) + .toContain('root-functions-directory'); + }); + + it('recognizes the bare-root api convention as its own shape', () => { + // `api/handler.ts` with no framework router is the Vercel convention. Next owns `pages/api` and + // `app/api`, which the endpoint walk already reads, so this stays separate rather than being folded + // into `vercel` — a project can use one without the other. + expect(shapesOf({ 'api/handler.ts': 'export default () => {}' })).toContain('root-api-directory'); + }); +}); + +describe('what it refuses to claim', () => { + it('finds nothing in a purely client-side project', () => { + const shapes = shapesOf({ + 'package.json': JSON.stringify({ dependencies: { react: '18' }, devDependencies: { vite: '5' } }), + 'src/main.tsx': 'export const App = () => null;', + 'index.html': '
', + }); + + // The finding this layer is FOR: nothing here declares a server. That is still not the same claim as + // "this app has no server-side runtime" — see the note the map emits — but it is the honest input to it. + expect(shapes).toEqual([]); + }); + + it('ignores an empty platform directory', () => { + // Scaffolding left behind by a template, or a directory someone created and abandoned. Counting it + // would make every project that once considered serverless look like it ships it. + const dir = project({ 'package.json': '{}' }); + mkdirSync(join(dir, 'netlify', 'functions'), { recursive: true }); + mkdirSync(join(dir, 'api'), { recursive: true }); + + expect(detectDeploymentShapes(dir)).toEqual([]); + }); + + it('ignores a directory holding no source file', () => { + expect(shapesOf({ 'api/README.md': '# planned', 'api/notes.txt': 'later' })).toEqual([]); + }); + + it('returns an empty list for a project it cannot read at all', () => { + // Fail-open, like everything else here: a missing directory is "found none", and the map says + // explicitly that this is not evidence of absence. + expect(detectDeploymentShapes(join(tmpdir(), 'ps-deploy-does-not-exist-'.concat(String(Date.now()))))).toEqual([]); + }); +}); + +describe('the map carries the evidence, not just the label', () => { + it('reports each shape with the artifact that proved it', async () => { + const dir = project({ + 'package.json': JSON.stringify({ dependencies: { react: '18' } }), + 'netlify.toml': '[build]\n publish = "dist"', + 'netlify/functions/submit.ts': 'export default async () => new Response("ok")', + 'src/main.tsx': 'export const App = () => null;', + }); + + const { map } = await buildInputMap(dir); + const shapes = map!.deploymentShapes ?? []; + + // A consumer has to be able to say WHY it classified a project. A bare label cannot be argued with. + expect(shapes.map((s) => s.shape)).toEqual(['netlify', 'netlify-functions']); + expect(shapes.map((s) => s.source)).toEqual(['netlify.toml', 'netlify/functions']); + }); + + it('carries the caveat when the list is EMPTY, which is the case that needs it', async () => { + // The state a consumer could read as "static", and therefore the one state that must not rely on + // type documentation a JSON reader never sees. Attaching the caveat only to non-empty results put + // the warning everywhere except the case it warns about. + const { map } = await buildInputMap(project({ + 'package.json': JSON.stringify({ dependencies: { react: '18' }, devDependencies: { vite: '5' } }), + 'src/main.tsx': 'export const App = () => null;', + })); + + expect(map!.deploymentShapes).toEqual([]); + + const note = map!.coverage.notes.find((n) => n.includes('deploymentShapes')); + expect(note, 'an empty list must still say what it does not mean').toBeDefined(); + expect(note).toContain('EMPTY'); + expect(note).toContain('not evidence the app has no server-side runtime'); + // And it names why the absence is unreliable rather than leaving it as an assertion. + expect(note).toContain('cannot parse'); + }); + + it('distinguishes a declared deployment from an ambiguous application folder', async () => { + const { map } = await buildInputMap(project({ + 'package.json': '{}', + 'wrangler.toml': 'name = "app"', + 'api/client.ts': 'export const get = () => fetch("/x");', + })); + const byShape = Object.fromEntries((map!.deploymentShapes ?? []).map((s) => [s.shape, s.evidence])); + + // `api/client.ts` is an ordinary front-end folder. It is reported — a consumer may want to look — + // but as `layout`, so a classifier cannot read a server runtime out of it alone. + expect(byShape['cloudflare-workers']).toBe('config'); + expect(byShape['root-api-directory']).toBe('layout'); + }); + + it('states in the notes that a non-empty list differs in strength', async () => { + const dir = project({ + 'package.json': JSON.stringify({ dependencies: { react: '18' } }), + 'wrangler.toml': 'name = "app"', + 'src/main.ts': 'export const x = 1;', + }); + + const { map } = await buildInputMap(dir); + const note = map!.coverage.notes.find((n) => n.includes('deploymentShapes')); + + // The whole point of the field, written where a consumer reading the document will see it. + expect(note, 'the map must say what the field does and does not mean').toBeDefined(); + expect(note).toContain('POSITIVE EVIDENCE ONLY'); + expect(note).toContain('must not on its own be read as a server runtime'); + }); + + it('refuses an artifact that resolves outside the project', () => { + // A symlinked `api/` pointing at a sibling workspace would otherwise become THIS project's + // deployment evidence, and the map would describe a runtime belonging to different code. Same rule + // the source walk applies, for the same reason. + const outside = project({ 'handler.ts': 'export default () => new Response("ok")' }); + const dir = project({ 'package.json': '{}' }); + symlinkSync(outside, join(dir, 'api'), 'dir'); + + expect(detectDeploymentShapes(dir)).toEqual([]); + // Opt in explicitly and it is evidence again — the boundary is a default, not a hard refusal. + expect(detectDeploymentShapes(dir, { followOutside: true }).map((s) => s.shape)) + .toContain('root-api-directory'); + }); + + it('refuses a config file that resolves outside the project', () => { + const outside = project({ 'vercel.json': '{}' }); + const dir = project({ 'package.json': '{}' }); + symlinkSync(join(outside, 'vercel.json'), join(dir, 'vercel.json')); + + expect(detectDeploymentShapes(dir)).toEqual([]); + }); + + it('does not count a linked entry inside a provider directory as source', () => { + // A real property, stated for the reason it actually holds: `readdirSync(withFileTypes)` classifies a + // symlink as neither file nor directory, so a linked entry cannot satisfy the "holds real source" + // test — no boundary check needed at that hop, and adding one there was dead code. + // + // The cost is that a legitimate in-project link inside `netlify/functions` also does not count. That + // errs toward reporting no shape, which the map already says is not evidence of absence. + const outside = project({ 'index.ts': 'export default () => new Response("ok")' }); + const dir = project({ 'package.json': '{}' }); + mkdirSync(join(dir, 'netlify', 'functions'), { recursive: true }); + symlinkSync(outside, join(dir, 'netlify', 'functions', 'hello'), 'dir'); + symlinkSync(join(outside, 'index.ts'), join(dir, 'netlify', 'functions', 'linked.ts')); + + expect(detectDeploymentShapes(dir)).toEqual([]); + // Even with the boundary opt-out, because the reason is the entry classification and not the boundary. + expect(detectDeploymentShapes(dir, { followOutside: true })).toEqual([]); + }); + + it('keeps the field additive, so a v3 reader is unaffected', async () => { + const { map } = await buildInputMap(project({ 'package.json': '{}' })); + + // Version stays 3 on purpose: a new optional field is not a silent-failure change, and bumping would + // make every existing consumer reject the document instead. + expect(map!.version).toBe(3); + expect(map!.deploymentShapes).toEqual([]); + }); +});