From fa258af7618392d3ef03775507136d29927556f5 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 11:08:14 +0200 Subject: [PATCH 1/3] Report the deployment artifacts a project declares MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for saying an app has no server-side runtime, and specifically for not saying it wrongly. The negative is the dangerous claim. A serverless handler this extractor cannot parse produces no endpoint, and an empty endpoint list is indistinguishable from an app that genuinely has no server. A consumer classifying on that basis would tell the owner of an unparsed Netlify function that there is nothing to protect — and nothing anywhere would raise. Note that the completeness flag does not help: `importsComplete` says the IMPORT INVENTORY is complete, not that entry-point recognition is, so an unrecognised framework leaves every coverage counter clean over a codebase full of server code. So this reports what is PRESENT: a `vercel.json`, a `netlify/functions` directory, a `wrangler.toml`, a Pages `_worker.js`, `supabase/functions`, a root `functions/` or `api/` directory. Each finding names the file or directory that proved it, because a classification a consumer cannot explain is one it should not act on. Two judgements worth stating. A root `functions/` directory is Cloudflare Pages Functions, Firebase, or a Deno layout depending on the platform, and nothing in the repository reliably distinguishes them — so it is reported as `root-functions-directory` rather than attributed to a provider that may not be involved. And a platform directory holding no source file is scaffolding, not a deployment: counting it would make every project that once considered serverless look like it ships it. Cheap by construction — a handful of stats at known paths, no walking — and fail-open: an unreadable project yields an empty list. The map says in its notes what that empty list does and does not mean, since "we recognized none" and "this app has no server" are the two readings that must not be conflated. A definitive claim needs deployment or build attestation, which source analysis cannot supply. Additive, so still version 3. Mutation-checked: dropping the empty-directory guard and removing the one-level descent each fail their own assertions and nothing else. Full suite 1155 passed, typecheck and the capability contract clean. --- src/map/extract.ts | 8 +- src/map/sources.ts | 94 ++++++++++++++++++ src/map/types.ts | 21 ++++ tests/map/deployment-shapes.test.ts | 149 ++++++++++++++++++++++++++++ 4 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 tests/map/deployment-shapes.test.ts diff --git a/src/map/extract.ts b/src/map/extract.ts index f902e47..e2ccea1 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,15 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac } } catch { /* not available */ } + const deploymentShapes = detectDeploymentShapes(cwd); + if (deploymentShapes.length > 0) { + notes.push(`\`deploymentShapes\` records ${deploymentShapes.length} deployment artifact(s) the project declares (${deploymentShapes.map((s) => s.shape).join(', ')}). POSITIVE EVIDENCE ONLY: an empty list means none was recognized, never that 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.`); + } + 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..b8299fe 100644 --- a/src/map/sources.ts +++ b/src/map/sources.ts @@ -112,3 +112,97 @@ 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". +const DEPLOYMENT_SHAPES: Array<{ shape: string; 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', files: ['vercel.json'] }, + { shape: 'netlify', files: ['netlify.toml'] }, + // Wrangler names a Workers/Pages deployment. `.jsonc` and `.json` are both current spellings. + { shape: 'cloudflare-workers', 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', files: ['_worker.js', '_worker.ts'] }, + { shape: 'netlify-functions', dirs: ['netlify/functions', 'netlify/edge-functions'] }, + { shape: 'supabase-functions', 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', 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', dirs: ['api'] }, +]; + +export interface DeploymentShape { + /** Which shape was recognized. */ + shape: string; + /** The artifact that proved it, repo-relative — so a consumer can show its evidence. */ + source: string; +} + +/** + * 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". + */ +export function detectDeploymentShapes(cwd: string): DeploymentShape[] { + const found: DeploymentShape[] = []; + + for (const candidate of DEPLOYMENT_SHAPES) { + for (const file of candidate.files ?? []) { + try { + if (statSync(join(cwd, file)).isFile()) { + found.push({ shape: candidate.shape, source: file }); + break; // one spelling is enough; the shape is the claim, not the filename + } + } catch { /* not this one */ } + } + + for (const dir of candidate.dirs ?? []) { + 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. + if (statSync(join(cwd, dir)).isDirectory() && holdsSourceFile(join(cwd, dir))) { + found.push({ shape: candidate.shape, source: dir }); + break; + } + } catch { /* not this one */ } + } + } + + return found; +} + +/** Whether a directory holds at least one source file, one level down included. */ +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..cfbe090 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -257,6 +257,13 @@ export interface Flow { line?: number; } +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; +} + export interface Coverage { /** Adapter that produced the map. */ adapter: string; @@ -391,6 +398,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..0cdc6a4 --- /dev/null +++ b/tests/map/deployment-shapes.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } 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('states in the notes that an empty list is not evidence of absence', 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('never that the app has no server-side runtime'); + }); + + 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([]); + }); +}); From bb0ef1664a4a690c7b71051d4e3de07be4bb872c Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 11:16:23 +0200 Subject: [PATCH 2/3] Warn on the empty list, refuse escaping artifacts, and rank the evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, and one of my own tests turned out to be passing for the wrong reason. **The caveat was missing from the only case that needs it.** The note was pushed only when a shape was found, so a map with `deploymentShapes: []` — the one state a consumer could read as "static" — carried no serialized warning at all. Type documentation does not travel to a JSON reader. The note is now unconditional, with the empty case spelling out why the absence is unreliable: an unparsed handler produces no endpoint, and entry-point recognition has no completeness flag (`importsComplete` covers the import inventory only). **An artifact that resolves outside the project is no longer evidence.** `statSync` follows symlinks, so a linked `api/` or `vercel.json` pointing at a sibling workspace became this project's deployment evidence — the map would describe a runtime belonging to different code. Same boundary rule the source walk applies, with the same `followOutside` opt-out. **Findings now carry their strength**, so a classifier cannot read more into one than it says: 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/) `api/client.ts` is a normal front-end folder and `api/handler.ts` is a platform function; the directory name is identical. Ranking it in the data rather than in a comment is what stops the next layer concluding a server runtime from a folder name — it may use `layout` to stay undecided, and no more. **And the correction.** Mutation-testing the boundary refusal failed two of three symlink tests: the nested one stayed green. `readdirSync(withFileTypes)` classifies a symlink as neither file nor directory, so a linked entry can never satisfy the source test and the per-hop boundary checks I had added there were unreachable. They are gone, the property is now asserted for the reason it actually holds, and the accepted cost — a legitimate in-project link inside a provider directory not counting — is written down. 17 tests here, full suite 1160 passed, typecheck and capability contract clean. Mutation-checked: making the note conditional, removing the boundary refusal, and mislabelling a layout folder as `config` each fail their own assertions and nothing else. --- src/map/extract.ts | 12 ++-- src/map/sources.ts | 86 ++++++++++++++++++++++++----- src/map/types.ts | 12 ++++ tests/map/deployment-shapes.test.ts | 79 +++++++++++++++++++++++++- 4 files changed, 167 insertions(+), 22 deletions(-) diff --git a/src/map/extract.ts b/src/map/extract.ts index e2ccea1..017ecaa 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -232,10 +232,14 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac } } catch { /* not available */ } - const deploymentShapes = detectDeploymentShapes(cwd); - if (deploymentShapes.length > 0) { - notes.push(`\`deploymentShapes\` records ${deploymentShapes.length} deployment artifact(s) the project declares (${deploymentShapes.map((s) => s.shape).join(', ')}). POSITIVE EVIDENCE ONLY: an empty list means none was recognized, never that 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.`); - } + 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, diff --git a/src/map/sources.ts b/src/map/sources.ts index b8299fe..4755bec 100644 --- a/src/map/sources.ts +++ b/src/map/sources.ts @@ -125,25 +125,39 @@ export function isInside(candidate: string, boundary: string): boolean { // 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". -const DEPLOYMENT_SHAPES: Array<{ shape: string; files?: string[]; dirs?: string[] }> = [ +// +// 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. +type ShapeEvidence = 'config' | 'provider-directory' | 'layout'; + +const DEPLOYMENT_SHAPES: Array<{ shape: string; evidence: ShapeEvidence; 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', files: ['vercel.json'] }, - { shape: 'netlify', files: ['netlify.toml'] }, + { 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', files: ['wrangler.toml', 'wrangler.jsonc', 'wrangler.json'] }, + { 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', files: ['_worker.js', '_worker.ts'] }, - { shape: 'netlify-functions', dirs: ['netlify/functions', 'netlify/edge-functions'] }, - { shape: 'supabase-functions', dirs: ['supabase/functions'] }, + { 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', dirs: ['functions'] }, + { 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', dirs: ['api'] }, + { shape: 'root-api-directory', evidence: 'layout', dirs: ['api'] }, ]; export interface DeploymentShape { @@ -151,6 +165,15 @@ export interface DeploymentShape { shape: string; /** The artifact that proved it, repo-relative — so a consumer can show its evidence. */ source: string; + /** How strong the finding is — see the note above `DEPLOYMENT_SHAPES`. */ + evidence: ShapeEvidence; +} + +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; } /** @@ -158,26 +181,46 @@ export interface DeploymentShape { * * 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): DeploymentShape[] { +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(join(cwd, file)).isFile()) { - found.push({ shape: candidate.shape, source: file }); + 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. - if (statSync(join(cwd, dir)).isDirectory() && holdsSourceFile(join(cwd, dir))) { - found.push({ shape: candidate.shape, source: dir }); + // `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 */ } @@ -187,7 +230,20 @@ export function detectDeploymentShapes(cwd: string): DeploymentShape[] { return found; } -/** Whether a directory holds at least one source file, one level down included. */ +/** + * 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 })) { diff --git a/src/map/types.ts b/src/map/types.ts index cfbe090..7a8a989 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -262,6 +262,18 @@ export interface DeploymentShape { 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: 'config' | 'provider-directory' | 'layout'; } export interface Coverage { diff --git a/tests/map/deployment-shapes.test.ts b/tests/map/deployment-shapes.test.ts index 0cdc6a4..d895843 100644 --- a/tests/map/deployment-shapes.test.ts +++ b/tests/map/deployment-shapes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +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'; @@ -122,7 +122,40 @@ describe('the map carries the evidence, not just the label', () => { expect(shapes.map((s) => s.source)).toEqual(['netlify.toml', 'netlify/functions']); }); - it('states in the notes that an empty list is not evidence of absence', async () => { + 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"', @@ -135,7 +168,47 @@ describe('the map carries the evidence, not just the label', () => { // 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('never that the app has no server-side runtime'); + 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 () => { From cda8ddff616054ee2bba297482990d9365e68b36 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 11:23:54 +0200 Subject: [PATCH 3/3] Declare the deployment contract once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DeploymentShape` existed twice — structurally identical in `sources.ts` and `types.ts` — so the evidence vocabulary had two definitions and nothing comparing them. That is the drift this branch spent its review cycles closing everywhere else, left sitting in the type layer. `types.ts` owns it, since it is the document's contract, and the union is named (`DeploymentEvidence`) rather than inlined so the detector and the schema cannot disagree about which strengths exist. `sources.ts` imports both. `DeploymentScanOptions` stays where it is — it describes an argument to a scan, not a field in the document. Type-only change: full suite 1160 passed, typecheck and capability contract clean. --- src/map/sources.ts | 17 +++++------------ src/map/types.ts | 11 ++++++++++- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/map/sources.ts b/src/map/sources.ts index 4755bec..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). @@ -137,9 +138,10 @@ export function isInside(candidate: string, boundary: string): boolean { // 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. -type ShapeEvidence = 'config' | 'provider-directory' | 'layout'; - -const DEPLOYMENT_SHAPES: Array<{ shape: string; evidence: ShapeEvidence; files?: string[]; dirs?: string[] }> = [ +// +// `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'] }, @@ -160,15 +162,6 @@ const DEPLOYMENT_SHAPES: Array<{ shape: string; evidence: ShapeEvidence; files?: { shape: 'root-api-directory', evidence: 'layout', dirs: ['api'] }, ]; -export interface DeploymentShape { - /** Which shape was recognized. */ - shape: string; - /** The artifact that proved it, repo-relative — so a consumer can show its evidence. */ - source: string; - /** How strong the finding is — see the note above `DEPLOYMENT_SHAPES`. */ - evidence: ShapeEvidence; -} - export interface DeploymentScanOptions { /** Project boundary (a real path). Candidates resolving outside it are refused. */ boundary?: string; diff --git a/src/map/types.ts b/src/map/types.ts index 7a8a989..a973ff8 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -257,6 +257,15 @@ 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; @@ -273,7 +282,7 @@ export interface DeploymentShape { * 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: 'config' | 'provider-directory' | 'layout'; + evidence: DeploymentEvidence; } export interface Coverage {