From bb159c23fd74222bb5659511d82b8b2a64a67eaa Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 11:37:24 +0200 Subject: [PATCH 1/4] Classify whether the app has a server side, from positive signals only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 2 on the evidence layer: `serverSurface` reports `server-runtime-detected`, `static-build-detected` or `unknown`, each with the signals that produced it. Why it is worth answering: an app with no server runtime cannot run a request guard, so its advisories are dependency and bundle hygiene rather than request-path risk. Told plainly that is useful. Told wrongly it is "nothing to protect here" on an app nobody could read, which is the worst thing this analysis can emit — so the static state is the one that has to be hard to reach. Three rules keep it from becoming the default for an unrecognised stack: 1. A static generator must be NAMED. Vite, CRA, Gatsby, Parcel, the SvelteKit static adapter, Astro with no SSR adapter, and Next only when it actually exports statically (a `next export` script or `output: 'export'`, read textually — parsing a config would mean executing it). 2. A server-framework dependency blocks it. `express` installed with no endpoint parsed is a parsing gap, not a static site. 3. Any deployment shape blocks it, including a `layout` one. A root `api/` holding source may be a front-end helper or a pile of platform functions, and from here they are the same folder — that belongs in `unknown` rather than in either claim. A `layout` shape also cannot produce `server-runtime-detected`, which is what the evidence strengths from the previous change exist for: the classifier consumes the distinction instead of re-deriving it. Every state carries its own caveat in the notes, in all three cases. `static-build-detected` says it is not deployment attestation — it describes the source, and a function added at the platform level never appears here. `unknown` says it must not be read as "no server side", and names why: an unparsed framework produces no endpoints, and entry-point recognition has no completeness flag. Mutation-testing found a hole in my own tests rather than in the code. Treating plain `next` as a static generator changed no state, because the server-dependency rule blocks it independently — so that rule was untested, not redundant. The evidence list is consumer-visible, and a `static-generator` signal inside a state that says otherwise invites exactly the wrong displayed reason, so it is now asserted directly. 17 tests here, full suite 1176 passed, typecheck and capability contract clean. Additive, so still version 3. --- src/map/extract.ts | 8 ++ src/map/surface.ts | 196 ++++++++++++++++++++++++++++ src/map/types.ts | 38 ++++++ tests/map/server-surface.test.ts | 215 +++++++++++++++++++++++++++++++ 4 files changed, 457 insertions(+) create mode 100644 src/map/surface.ts create mode 100644 tests/map/server-surface.test.ts diff --git a/src/map/extract.ts b/src/map/extract.ts index 017ecaa..6bf5836 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -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'; @@ -241,10 +242,17 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac ? '`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.`); + // 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); + notes.push(surfaceNote(serverSurface)); + return { version: 3, framework: detectFramework(cwd), deploymentShapes, + serverSurface, endpoints, imports: importList, apiInvocations: invocationList, diff --git a/src/map/surface.ts b/src/map/surface.ts new file mode 100644 index 0000000..fbb17aa --- /dev/null +++ b/src/map/surface.ts @@ -0,0 +1,196 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { DeploymentShape, ServerSurface, SurfaceSignal } from './types.js'; + +// Does this app have a server side at all? +// +// The question is worth answering because the answer changes what protection MEANS: an app with no server +// runtime cannot run a request guard, so its advisories are dependency and bundle hygiene rather than +// request-path risk. Told plainly, that is useful. Told wrongly, it is the worst output this analysis can +// produce — "nothing to protect here" on an app we simply failed to read. +// +// So the classification is built from POSITIVE signals on both sides, and the honest answer is usually +// neither: +// +// server-runtime-detected a recognized endpoint, or an artifact that declares a deployment +// static-build-detected a static generator identified AND nothing suggesting a server surface +// unknown everything else — including "we found nothing and cannot say why" +// +// Three rules keep `static-build-detected` from becoming the default for an unrecognised stack: +// +// 1. It needs a static generator NAMED. Absence of server signals is not evidence; `endpoints: []` is +// what an unparsed framework looks like, and entry-point recognition has no completeness flag. +// 2. A server-framework dependency blocks it. An app with `express` installed and no endpoint we could +// read is a parsing gap, not a static site. +// 3. Any deployment shape blocks it — including `layout`. A root `api/` folder holding source may be a +// front-end helper or a pile of platform functions, and from here they are the same folder. That +// ambiguity belongs in `unknown`, not in a claim either way. +// +// Even `static-build-detected` is not deployment attestation: it says the source describes a static build, +// not that nothing server-side is deployed. Only build or platform metadata can carry that, and this +// analysis never sees it. + +/** Dependencies that build a static bundle and, on their own, no server. */ +const STATIC_GENERATORS: Array<{ dep: string; label: string }> = [ + { dep: 'vite', label: 'vite' }, + { dep: 'react-scripts', label: 'create-react-app' }, + { dep: 'gatsby', label: 'gatsby' }, + { dep: 'parcel', label: 'parcel' }, + { dep: '@sveltejs/adapter-static', label: 'sveltekit-static-adapter' }, +]; + +/** + * Dependencies that mean a server, so a static claim is off the table. + * + * Deliberately wider than the framework detector: this list only has to answer "might this app serve + * requests", and over-answering yes costs a claim we would rather not make anyway. + */ +const SERVER_DEPENDENCIES = [ + 'express', 'fastify', 'hono', 'koa', '@nestjs/core', '@hapi/hapi', 'h3', 'polka', 'restify', + '@tanstack/react-start', '@tanstack/start', '@tanstack/solid-start', '@sveltejs/kit', 'nuxt', 'remix', + '@remix-run/node', '@remix-run/server-runtime', +]; + +/** Astro and Next ship both modes, so the adapter or the output setting decides. */ +const SSR_ADAPTERS = ['@astrojs/node', '@astrojs/vercel', '@astrojs/cloudflare', '@astrojs/netlify', '@astrojs/deno']; + +interface Manifest { + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; +} + +function readManifest(cwd: string): Manifest | null { + try { + const parsed = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); + + return typeof parsed === 'object' && parsed !== null ? parsed as Manifest : null; + } catch { + return null; // no manifest, or unreadable — either way nothing is identified from it + } +} + +/** Whether a Next project is configured to emit a static export rather than run a server. */ +function nextExportsStatically(cwd: string, manifest: Manifest): string | null { + const scripts = Object.values(manifest.scripts ?? {}).join(' '); + if (/\bnext\s+export\b/.test(scripts)) return 'next export (build script)'; + + for (const file of ['next.config.js', 'next.config.mjs', 'next.config.ts']) { + try { + // A textual read, and scoped to the one setting that decides it. Parsing the config would mean + // executing it, which this analysis will not do. + if (/output\s*:\s*['"]export['"]/.test(readFileSync(join(cwd, file), 'utf8'))) { + return `output: 'export' (${file})`; + } + } catch { /* next candidate */ } + } + + return null; +} + +/** + * Static generators this project positively identifies, each with the dependency or setting that named it. + * + * Astro and Next are conditional: both ship a server mode, so `astro` alone says nothing and an SSR adapter + * rules the static reading out entirely. + */ +function staticSignals(cwd: string): SurfaceSignal[] { + const manifest = readManifest(cwd); + if (manifest === null) return []; + + const deps = { ...manifest.dependencies, ...manifest.devDependencies }; + const signals: SurfaceSignal[] = []; + + for (const generator of STATIC_GENERATORS) { + if (deps[generator.dep] !== undefined) { + signals.push({ signal: 'static-generator', source: generator.label }); + } + } + + if (deps['astro'] !== undefined && !SSR_ADAPTERS.some((adapter) => deps[adapter] !== undefined)) { + signals.push({ signal: 'static-generator', source: 'astro (no SSR adapter)' }); + } + + if (deps['next'] !== undefined) { + const exported = nextExportsStatically(cwd, manifest); + if (exported !== null) signals.push({ signal: 'static-generator', source: `next: ${exported}` }); + } + + return signals; +} + +/** Server-framework dependencies present, each named — they block a static conclusion. */ +function serverDependencySignals(cwd: string): SurfaceSignal[] { + const manifest = readManifest(cwd); + if (manifest === null) return []; + + const deps = { ...manifest.dependencies, ...manifest.devDependencies }; + const found = SERVER_DEPENDENCIES.filter((dep) => deps[dep] !== undefined); + + // `next` counts as a server dependency UNLESS the project exports statically, which `staticSignals` + // establishes from the same manifest. + if (deps['next'] !== undefined && nextExportsStatically(cwd, manifest) === null) found.push('next'); + if (deps['astro'] !== undefined) { + for (const adapter of SSR_ADAPTERS) if (deps[adapter] !== undefined) found.push(adapter); + } + + return found.map((dep) => ({ signal: 'server-dependency', source: dep })); +} + +/** + * Classify the app's server surface from what was positively found. + * + * `endpointCount` and `deploymentShapes` come from the analysis that already ran; the manifest signals are + * read here. Nothing in this function infers from absence except the final fall-through to `unknown`, + * which is the one honest thing absence supports. + */ +export function classifyServerSurface( + cwd: string, + endpointCount: number, + deploymentShapes: DeploymentShape[], +): ServerSurface { + const evidence: SurfaceSignal[] = []; + + if (endpointCount > 0) { + evidence.push({ signal: 'endpoint', source: `${endpointCount} recognized entry point(s)` }); + } + for (const shape of deploymentShapes) { + evidence.push({ + signal: shape.evidence === 'layout' ? 'ambiguous-layout' : 'deployment-artifact', + source: `${shape.shape} (${shape.source})`, + }); + } + + const servers = serverDependencySignals(cwd); + const statics = staticSignals(cwd); + + // A recognized endpoint or a declared deployment settles it. `layout` shapes deliberately do not: + // `api/client.ts` is an ordinary front-end folder, and concluding a runtime from a folder name would + // classify a pile of client-only apps as having one. + const declaresDeployment = deploymentShapes.some((shape) => shape.evidence !== 'layout'); + if (endpointCount > 0 || declaresDeployment) { + return { state: 'server-runtime-detected', evidence }; + } + + evidence.push(...servers, ...statics); + + const blocked = servers.length > 0 || deploymentShapes.length > 0; + if (statics.length > 0 && !blocked) { + return { state: 'static-build-detected', evidence }; + } + + return { state: 'unknown', evidence }; +} + +/** The sentence that goes with each state, so a consumer states the same limits the analysis does. */ +export function surfaceNote(surface: ServerSurface): string { + if (surface.state === 'server-runtime-detected') { + return 'serverSurface: a server runtime was recognized in the analysed source (see its evidence). Request-path protection applies to this app.'; + } + + if (surface.state === 'static-build-detected') { + return 'serverSurface: a static build was identified and no server runtime was recognized in the analysed source. This is NOT deployment attestation — it describes the source, not what is deployed, and a serverless function added at the platform level would not appear here. Advisories against this app are dependency and bundle concerns rather than request-path risk.'; + } + + return 'serverSurface is UNKNOWN: neither a server runtime nor a static build could be positively identified. This is the honest answer for an unrecognised stack, and it must not be read as "no server side" — an unparsed framework produces no endpoints, and entry-point recognition has no completeness flag.'; +} diff --git a/src/map/types.ts b/src/map/types.ts index a973ff8..29865d0 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -285,6 +285,40 @@ export interface DeploymentShape { evidence: DeploymentEvidence; } +/** One reason behind a `serverSurface` state, so a consumer can explain the classification. */ +export interface SurfaceSignal { + /** + * What kind of signal this is: + * `endpoint` a recognized entry point was parsed + * `deployment-artifact` a `config` or `provider-directory` deployment shape + * `ambiguous-layout` a `layout` shape — present, and not sufficient for a runtime conclusion + * `server-dependency` a server framework in the manifest + * `static-generator` a static build tool positively identified + */ + signal: 'endpoint' | 'deployment-artifact' | 'ambiguous-layout' | 'server-dependency' | 'static-generator'; + /** The thing that produced the signal — a dependency name, a file, a count. */ + source: string; +} + +/** + * Whether this app appears to have a server side, from positive signals on both sides. + * + * Exists because the answer changes what protection means: an app with no server runtime cannot run a + * request guard, so its advisories are dependency and bundle hygiene rather than request-path risk. + * + * `static-build-detected` requires a static generator to be NAMED, and is blocked by any deployment shape + * (including an ambiguous `layout` one) or any server-framework dependency. It is still not deployment + * attestation: it describes the source, not what is deployed, and a function added at the platform level + * would not appear here. `unknown` is the honest answer for an unrecognised stack and must never be read as + * "no server side" — an unparsed framework produces no endpoints, and entry-point recognition has no + * completeness flag. + */ +export interface ServerSurface { + state: 'server-runtime-detected' | 'static-build-detected' | 'unknown'; + /** Every signal that produced the state, so the classification can be shown rather than asserted. */ + evidence: SurfaceSignal[]; +} + export interface Coverage { /** Adapter that produced the map. */ adapter: string; @@ -433,6 +467,10 @@ export interface SiteInputMap { * Additive, so still version 3: a v3 reader that ignores it keeps behaving correctly. */ deploymentShapes?: DeploymentShape[]; + /** + * Whether this app appears to have a server side — see `ServerSurface`. Additive, so still version 3. + */ + serverSurface?: ServerSurface; endpoints: Endpoint[]; coverage: Coverage; /** diff --git a/tests/map/server-surface.test.ts b/tests/map/server-surface.test.ts new file mode 100644 index 0000000..dfc2fc6 --- /dev/null +++ b/tests/map/server-surface.test.ts @@ -0,0 +1,215 @@ +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 { buildInputMap } from '../../src/map/index.js'; +import type { InputMap } from '../../src/map/types.js'; + +// Whether the app has a server side at all. +// +// The reason to answer it: an app with no server runtime cannot run a request guard, so its advisories are +// dependency and bundle hygiene rather than request-path risk. Saying that plainly is useful. Saying it +// wrongly is the worst output this analysis can produce — "nothing to protect here" on an app nobody could +// read — so most of this file is about the cases that must come back `unknown`. +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-surface-')); + 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 mapOf = async (files: Record): Promise => { + const { map, error } = await buildInputMap(project(files)); + expect(error).toBeUndefined(); + return map!; +}; +const stateOf = async (files: Record) => (await mapOf(files)).serverSurface?.state; + +const VITE_APP = { + 'package.json': JSON.stringify({ dependencies: { react: '18' }, devDependencies: { vite: '5' } }), + 'src/main.tsx': 'export const App = () => null;', + 'index.html': '
', +}; + +describe('a server runtime, when something positively shows one', () => { + it('reports a recognized endpoint as a server runtime', async () => { + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { express: '4' } }), + 'src/server.js': ` + const express = require("express"); + const app = express(); + app.get("/items", (req, res) => res.json([])); + module.exports = app; + `, + })).toBe('server-runtime-detected'); + }); + + it('reports a declared deployment even with no endpoint parsed', async () => { + // The case the evidence layer exists for: a handler this analysis cannot read still deploys. The + // artifact says so, and that outweighs an empty endpoint list. + expect(await stateOf({ + ...VITE_APP, + 'netlify.toml': '[build]\n publish = "dist"', + 'netlify/functions/submit.ts': 'export default async () => new Response("ok")', + })).toBe('server-runtime-detected'); + }); + + it('carries the evidence that produced the state', async () => { + const map = await mapOf({ + ...VITE_APP, + 'wrangler.toml': 'name = "app"', + }); + + // A consumer has to be able to show WHY, not just display a badge. + expect(map.serverSurface?.state).toBe('server-runtime-detected'); + expect(map.serverSurface?.evidence.map((e) => e.signal)).toContain('deployment-artifact'); + expect(map.serverSurface?.evidence.find((e) => e.signal === 'deployment-artifact')?.source) + .toContain('wrangler.toml'); + }); +}); + +describe('a static build, only when one is named', () => { + it('reports a static build for a client-only project', async () => { + expect(await stateOf(VITE_APP)).toBe('static-build-detected'); + }); + + it('recognizes the other common generators', async () => { + expect(await stateOf({ 'package.json': JSON.stringify({ devDependencies: { 'react-scripts': '5' } }) })) + .toBe('static-build-detected'); + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { gatsby: '5' } }) })) + .toBe('static-build-detected'); + expect(await stateOf({ 'package.json': JSON.stringify({ devDependencies: { '@sveltejs/adapter-static': '3' } }) })) + .toBe('static-build-detected'); + }); + + it('reads a Next project as static only when it exports statically', async () => { + // Next ships both modes, so the dependency says nothing on its own. + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { next: '14' } }) })) + .toBe('unknown'); + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' }, scripts: { build: 'next build && next export' } }), + })).toBe('static-build-detected'); + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': "module.exports = { output: 'export' };", + })).toBe('static-build-detected'); + }); + + it('does not list a static generator for a Next app that serves', async () => { + // Two independent rules keep plain `next` out of `static-build-detected`: it is not counted as a static + // generator, and it IS counted as a server dependency. Only the second decides the state, so mutating + // the first changed nothing observable — which made the first rule untested rather than redundant. + // + // The evidence list is consumer-visible, and a `static-generator` signal in it invites "static build + // detected" as a displayed reason for a state that says the opposite. + const map = await mapOf({ 'package.json': JSON.stringify({ dependencies: { next: '14' } }) }); + const signals = map.serverSurface?.evidence ?? []; + + expect(map.serverSurface?.state).toBe('unknown'); + expect(signals.filter((e) => e.signal === 'static-generator')).toEqual([]); + expect(signals.map((e) => e.source)).toContain('next'); + + // And when it does export statically, the generator signal appears with the setting that proved it. + const exported = await mapOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.mjs': "export default { output: 'export' };", + }); + expect(exported.serverSurface?.evidence.find((e) => e.signal === 'static-generator')?.source) + .toContain("output: 'export'"); + }); + + it('does not read Astro as static when an SSR adapter is installed', async () => { + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { astro: '4' } }) })) + .toBe('static-build-detected'); + expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { astro: '4', '@astrojs/node': '8' } }) })) + .toBe('unknown'); + }); + + it('says what the state does NOT mean, in the document itself', async () => { + const map = await mapOf(VITE_APP); + const note = map.coverage.notes.find((n) => n.includes('serverSurface')); + + // The claim a consumer would otherwise make for us. A static reading describes the source; a function + // added at the platform level is invisible to it. + expect(note).toContain('NOT deployment attestation'); + expect(note).toContain('not what is deployed'); + }); +}); + +describe('unknown, which is most of the interesting cases', () => { + it('refuses a static claim when a server framework is installed but no endpoint parsed', async () => { + // The defect this rule prevents: an unparsed framework produces no endpoints, which looks exactly like + // a static app. `express` in the manifest says otherwise, so the honest answer is that we do not know. + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { express: '4', vite: '5' } }), + 'src/main.tsx': 'export const App = () => null;', + })).toBe('unknown'); + }); + + it('refuses a static claim when an ambiguous api folder holds source', async () => { + // `api/client.ts` is an ordinary front-end helper; `api/handler.ts` is a platform function. From here + // they are the same folder, so neither claim is available. + expect(await stateOf({ + ...VITE_APP, + 'api/client.ts': 'export const get = () => fetch("/x");', + })).toBe('unknown'); + }); + + it('reports unknown for a stack it recognizes nothing in', async () => { + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { 'some-unknown-framework': '1' } }), + 'src/app.ts': 'export const handler = () => "hello";', + })).toBe('unknown'); + }); + + it('reports unknown with no manifest at all', async () => { + expect(await stateOf({ 'src/app.ts': 'export const x = 1;' })).toBe('unknown'); + }); + + it('says plainly that unknown is not "no server side"', async () => { + const map = await mapOf({ 'package.json': JSON.stringify({ dependencies: { 'x-framework': '1' } }) }); + const note = map.coverage.notes.find((n) => n.includes('serverSurface')); + + expect(note).toContain('UNKNOWN'); + expect(note).toContain('must not be read as "no server side"'); + // And it names the reason, rather than asserting the limit. + expect(note).toContain('no completeness flag'); + }); + + it('keeps the ambiguous layout visible in the evidence, so the state can be explained', async () => { + const map = await mapOf({ ...VITE_APP, 'functions/hello.js': 'export const onRequest = () => {}' }); + const signals = map.serverSurface?.evidence.map((e) => e.signal) ?? []; + + // Both halves are reported: the folder that blocked the static claim AND the generator that would + // otherwise have supported it. A state with no visible reason is one a consumer cannot act on. + expect(map.serverSurface?.state).toBe('unknown'); + expect(signals).toContain('ambiguous-layout'); + expect(signals).toContain('static-generator'); + }); +}); + +describe('the field stays safe to consume', () => { + it('is additive, so a v3 reader is unaffected', async () => { + const map = await mapOf(VITE_APP); + + expect(map.version).toBe(3); + expect(map.serverSurface?.state).toBeDefined(); + }); + + it('never claims a state without at least one signal behind it, except unknown', async () => { + for (const files of [VITE_APP, { 'package.json': JSON.stringify({ dependencies: { express: '4' } }) }]) { + const map = await mapOf(files); + if (map.serverSurface?.state !== 'unknown') { + expect(map.serverSurface?.evidence.length, 'a positive state needs evidence').toBeGreaterThan(0); + } + } + }); +}); From e48739d369fdf5fdc37230841a530a85daaf404d Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 12:09:21 +0200 Subject: [PATCH 2/4] A deployment config is not a server, and a comment is not configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four review findings, and the first two were wrong in the direction that loses protection. **A platform config alone no longer means a runtime.** A static site on Netlify has a `netlify.toml`; a static Vercel project has a `vercel.json`; a Pages project serving only assets has a `wrangler.toml`. Reading any of those as a server would have classified a large share of purely static apps as having one. `server-runtime-detected` now requires something that SERVES: a recognized endpoint, a worker entry (`_worker.js` — that file IS the server), or a provider function directory holding source. A config stays in the evidence and still blocks the static claim, because a project that deploys somewhere is not one this analysis can call server-free — but on its own the answer is `unknown`. The evidence vocabulary carries that distinction now, since it is the classifier's input: `runtime-entry`, `deployment-config`, `layout`. The rename beats layering a second taxonomy over `config` / `provider-directory`, which described where an artifact was found rather than what it proves. **The Next config is parsed, not pattern-matched.** The regex accepted `// output: 'export'`, the same words inside a string, and an example object nobody exports — each reclassifying an ordinary server-mode Next app as static. Comments never reach an AST, a string literal is one token, and only a value reachable from the module's export counts, so dead code is ignored too. Wrapper (`withPlugins({...})`) and named-variable forms are followed, since that is how real configs are written. Parsing is not executing: the config never runs. **A real static SvelteKit project is no longer permanently unknown.** Every one ships `@sveltejs/kit` alongside `@sveltejs/adapter-static`, and kit was an unconditional server dependency — so the adapter's signal could never win. The veto is now conditional, and the test that appeared to cover this installed the adapter with no kit, which is not a package set anyone ships. **Vite alone is not a static build.** It underpins SSR stacks, so an SSR companion (vike, vite-plugin-ssr, the React Router server packages) vetoes the static reading — conservative in the direction this state's product meaning requires. 23 tests here, full suite 1183 passed. Mutation-checked: restoring the any-shape rule, the regex, the unconditional kit veto, and dropping the companion veto each fail their own assertions and nothing else. --- src/map/extract.ts | 4 +- src/map/sources.ts | 32 +++--- src/map/surface.ts | 172 +++++++++++++++++++++++----- src/map/types.ts | 47 ++++---- tests/map/deployment-shapes.test.ts | 7 +- tests/map/server-surface.test.ts | 116 +++++++++++++++++-- 6 files changed, 304 insertions(+), 74 deletions(-) diff --git a/src/map/extract.ts b/src/map/extract.ts index 6bf5836..42b24da 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -240,12 +240,12 @@ 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); + const serverSurface = classifyServerSurface(cwd, endpoints.length, deploymentShapes, ts); notes.push(surfaceNote(serverSurface)); return { diff --git a/src/map/sources.ts b/src/map/sources.ts index 471d249..b182e8f 100644 --- a/src/map/sources.ts +++ b/src/map/sources.ts @@ -130,28 +130,34 @@ 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[] }> = [ // 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'] }, // 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. diff --git a/src/map/surface.ts b/src/map/surface.ts index fbb17aa..0a0bdf7 100644 --- a/src/map/surface.ts +++ b/src/map/surface.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import type { DeploymentShape, ServerSurface, SurfaceSignal } from './types.js'; +import type { DeploymentShape, ServerSurface, SurfaceSignal, TsModule } from './types.js'; // Does this app have a server side at all? // @@ -12,19 +12,27 @@ import type { DeploymentShape, ServerSurface, SurfaceSignal } from './types.js'; // So the classification is built from POSITIVE signals on both sides, and the honest answer is usually // neither: // -// server-runtime-detected a recognized endpoint, or an artifact that declares a deployment +// server-runtime-detected a recognized endpoint, or an artifact that SERVES (a worker entry, a provider +// function directory with source) // static-build-detected a static generator identified AND nothing suggesting a server surface // unknown everything else — including "we found nothing and cannot say why" // +// A deployment CONFIG is not in that first list, and that is the correction that matters most here: a static +// site on Netlify has a `netlify.toml`, a static Vercel project has a `vercel.json`, and a Pages project +// serving only assets has a `wrangler.toml`. Reading those as a runtime would have classified a large share +// of purely static apps as having a server. They stay visible in the evidence and they still rule out a +// confident static claim — a project that deploys somewhere is not one this analysis can call server-free — +// but the answer with nothing else behind it is `unknown`. +// // Three rules keep `static-build-detected` from becoming the default for an unrecognised stack: // // 1. It needs a static generator NAMED. Absence of server signals is not evidence; `endpoints: []` is // what an unparsed framework looks like, and entry-point recognition has no completeness flag. // 2. A server-framework dependency blocks it. An app with `express` installed and no endpoint we could // read is a parsing gap, not a static site. -// 3. Any deployment shape blocks it — including `layout`. A root `api/` folder holding source may be a -// front-end helper or a pile of platform functions, and from here they are the same folder. That -// ambiguity belongs in `unknown`, not in a claim either way. +// 3. Any deployment shape blocks it — config and `layout` included. A root `api/` folder holding source +// may be a front-end helper or a pile of platform functions, and from here they are the same folder. +// That ambiguity belongs in `unknown`, not in a claim either way. // // Even `static-build-detected` is not deployment attestation: it says the source describes a static build, // not that nothing server-side is deployed. Only build or platform metadata can carry that, and this @@ -39,6 +47,16 @@ const STATIC_GENERATORS: Array<{ dep: string; label: string }> = [ { dep: '@sveltejs/adapter-static', label: 'sveltekit-static-adapter' }, ]; +/** + * Build tools that turn a static bundler into an SSR stack. + * + * Vite is the awkward one: it is the default bundler for client-only apps AND the foundation of several + * server frameworks, so `vite` in a manifest is not by itself a static build. Anything here vetoes the + * static reading and leaves the app `unknown`, which is the conservative direction for a state whose product + * meaning is "no request-path protection needed". + */ +const SSR_COMPANIONS = ['vike', 'vite-plugin-ssr', '@react-router/node', '@react-router/serve', 'vite-plugin-node']; + /** * Dependencies that mean a server, so a static claim is off the table. * @@ -47,13 +65,32 @@ const STATIC_GENERATORS: Array<{ dep: string; label: string }> = [ */ const SERVER_DEPENDENCIES = [ 'express', 'fastify', 'hono', 'koa', '@nestjs/core', '@hapi/hapi', 'h3', 'polka', 'restify', - '@tanstack/react-start', '@tanstack/start', '@tanstack/solid-start', '@sveltejs/kit', 'nuxt', 'remix', + '@tanstack/react-start', '@tanstack/start', '@tanstack/solid-start', 'nuxt', 'remix', '@remix-run/node', '@remix-run/server-runtime', ]; +/** + * Frameworks that ship BOTH modes, where another dependency decides which one this project is. + * + * `@sveltejs/kit` is the case that made this necessary: every static SvelteKit app has it alongside + * `@sveltejs/adapter-static`, so treating kit as an unconditional server dependency made a real static + * SvelteKit project permanently `unknown` — and the test that "proved" the adapter worked installed the + * adapter with no kit, which is not a package set anyone ships. + */ +const CONDITIONAL_SERVER_DEPENDENCIES: Array<{ dep: string; staticWhen: string }> = [ + { dep: '@sveltejs/kit', staticWhen: '@sveltejs/adapter-static' }, +]; + /** Astro and Next ship both modes, so the adapter or the output setting decides. */ const SSR_ADAPTERS = ['@astrojs/node', '@astrojs/vercel', '@astrojs/cloudflare', '@astrojs/netlify', '@astrojs/deno']; +/** How each deployment-evidence level appears in the surface evidence list. */ +const SHAPE_SIGNAL: Record = { + 'runtime-entry': 'runtime-entry', + 'deployment-config': 'deployment-config', + layout: 'ambiguous-layout', +}; + interface Manifest { dependencies?: Record; devDependencies?: Record; @@ -70,31 +107,98 @@ function readManifest(cwd: string): Manifest | null { } } -/** Whether a Next project is configured to emit a static export rather than run a server. */ -function nextExportsStatically(cwd: string, manifest: Manifest): string | null { +/** + * Whether a Next project is configured to emit a static export rather than run a server. + * + * The config is PARSED, not pattern-matched. A regex over the file text accepts + * `// output: 'export'`, the same words inside a string, and an example block nobody exports — and each of + * those would have reclassified an ordinary server-mode Next app as static, which is the direction that + * loses protection. Parsing is also not the same as executing: the AST is read, the config never runs. + * + * Only a value reachable from the module's export counts, so dead code declaring `output: 'export'` is + * ignored too. The wrapper form (`withPlugins(config)`, `withMDX({...})`) is followed one level, since it + * is how most real Next configs are written. + */ +function nextExportsStatically(cwd: string, manifest: Manifest, ts: TsModule | undefined): string | null { const scripts = Object.values(manifest.scripts ?? {}).join(' '); + // Scripts are JSON strings — no comments to be fooled by. if (/\bnext\s+export\b/.test(scripts)) return 'next export (build script)'; - for (const file of ['next.config.js', 'next.config.mjs', 'next.config.ts']) { + if (ts === undefined) return null; // no compiler available: nothing is claimed from the config + + for (const file of ['next.config.js', 'next.config.mjs', 'next.config.ts', 'next.config.cjs']) { + let text: string; try { - // A textual read, and scoped to the one setting that decides it. Parsing the config would mean - // executing it, which this analysis will not do. - if (/output\s*:\s*['"]export['"]/.test(readFileSync(join(cwd, file), 'utf8'))) { - return `output: 'export' (${file})`; - } - } catch { /* next candidate */ } + text = readFileSync(join(cwd, file), 'utf8'); + } catch { + continue; + } + + try { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + if (exportedConfigIsStatic(sf, ts)) return `output: 'export' (${file})`; + } catch { /* unparseable config claims nothing */ } } return null; } +/** `output: 'export'` on the object this module actually exports. */ +function exportedConfigIsStatic(sf: any, ts: TsModule): boolean { + const objects: any[] = []; + + const collectFrom = (expr: any): void => { + if (!expr) return; + if (ts.isObjectLiteralExpression(expr)) { objects.push(expr); return; } + // `export default withPlugins({...})` / `module.exports = withMDX(config)` + if (ts.isCallExpression(expr)) { for (const arg of expr.arguments) collectFrom(arg); return; } + // `const nextConfig = {...}; export default nextConfig` + if (ts.isIdentifier(expr)) { + const name = expr.text; + const visit = (node: any): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) { + collectFrom(node.initializer); + } + ts.forEachChild(node, visit); + }; + visit(sf); + } + }; + + const findExports = (node: any): void => { + // `export default X` + if (ts.isExportAssignment(node)) collectFrom(node.expression); + // `module.exports = X` + if (ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) + && node.left.name.text === 'exports') { + collectFrom(node.right); + } + ts.forEachChild(node, findExports); + }; + findExports(sf); + + for (const object of objects) { + for (const property of object.properties) { + if (!ts.isPropertyAssignment(property)) continue; + const key = property.name; + const keyName = ts.isIdentifier(key) || ts.isStringLiteralLike(key) ? key.text : undefined; + if (keyName !== 'output') continue; + if (ts.isStringLiteralLike(property.initializer) && property.initializer.text === 'export') return true; + } + } + + return false; +} + /** * Static generators this project positively identifies, each with the dependency or setting that named it. * * Astro and Next are conditional: both ship a server mode, so `astro` alone says nothing and an SSR adapter * rules the static reading out entirely. */ -function staticSignals(cwd: string): SurfaceSignal[] { +function staticSignals(cwd: string, ts: TsModule | undefined): SurfaceSignal[] { const manifest = readManifest(cwd); if (manifest === null) return []; @@ -112,7 +216,7 @@ function staticSignals(cwd: string): SurfaceSignal[] { } if (deps['next'] !== undefined) { - const exported = nextExportsStatically(cwd, manifest); + const exported = nextExportsStatically(cwd, manifest, ts); if (exported !== null) signals.push({ signal: 'static-generator', source: `next: ${exported}` }); } @@ -120,16 +224,28 @@ function staticSignals(cwd: string): SurfaceSignal[] { } /** Server-framework dependencies present, each named — they block a static conclusion. */ -function serverDependencySignals(cwd: string): SurfaceSignal[] { +function serverDependencySignals(cwd: string, ts: TsModule | undefined): SurfaceSignal[] { const manifest = readManifest(cwd); if (manifest === null) return []; const deps = { ...manifest.dependencies, ...manifest.devDependencies }; const found = SERVER_DEPENDENCIES.filter((dep) => deps[dep] !== undefined); + // Both-mode frameworks: present, but not a server dependency when the project also installs the + // dependency that makes it static. + for (const conditional of CONDITIONAL_SERVER_DEPENDENCIES) { + if (deps[conditional.dep] !== undefined && deps[conditional.staticWhen] === undefined) { + found.push(conditional.dep); + } + } + + // An SSR companion turns a static bundler into a server stack, so it vetoes the static reading the same + // way a server framework does. `vite` alone is not a static build. + for (const companion of SSR_COMPANIONS) if (deps[companion] !== undefined) found.push(companion); + // `next` counts as a server dependency UNLESS the project exports statically, which `staticSignals` // establishes from the same manifest. - if (deps['next'] !== undefined && nextExportsStatically(cwd, manifest) === null) found.push('next'); + if (deps['next'] !== undefined && nextExportsStatically(cwd, manifest, ts) === null) found.push('next'); if (deps['astro'] !== undefined) { for (const adapter of SSR_ADAPTERS) if (deps[adapter] !== undefined) found.push(adapter); } @@ -148,6 +264,7 @@ export function classifyServerSurface( cwd: string, endpointCount: number, deploymentShapes: DeploymentShape[], + ts?: TsModule, ): ServerSurface { const evidence: SurfaceSignal[] = []; @@ -156,19 +273,20 @@ export function classifyServerSurface( } for (const shape of deploymentShapes) { evidence.push({ - signal: shape.evidence === 'layout' ? 'ambiguous-layout' : 'deployment-artifact', + signal: SHAPE_SIGNAL[shape.evidence], source: `${shape.shape} (${shape.source})`, }); } - const servers = serverDependencySignals(cwd); - const statics = staticSignals(cwd); + const servers = serverDependencySignals(cwd, ts); + const statics = staticSignals(cwd, ts); - // A recognized endpoint or a declared deployment settles it. `layout` shapes deliberately do not: - // `api/client.ts` is an ordinary front-end folder, and concluding a runtime from a folder name would - // classify a pile of client-only apps as having one. - const declaresDeployment = deploymentShapes.some((shape) => shape.evidence !== 'layout'); - if (endpointCount > 0 || declaresDeployment) { + // Only code that SERVES settles this: a recognized endpoint, a worker entry, or a provider function + // directory with source in it. A deployment config does not — a static site on Netlify has a + // `netlify.toml` and no server — and a `layout` folder does not either, since `api/client.ts` is an + // ordinary front-end helper. Both remain in the evidence, and both still block a static claim below. + const servesRequests = deploymentShapes.some((shape) => shape.evidence === 'runtime-entry'); + if (endpointCount > 0 || servesRequests) { return { state: 'server-runtime-detected', evidence }; } diff --git a/src/map/types.ts b/src/map/types.ts index 29865d0..35364f8 100644 --- a/src/map/types.ts +++ b/src/map/types.ts @@ -258,13 +258,20 @@ export interface Flow { } /** - * How strong a deployment finding is. Not all artifacts prove the same thing: + * What a deployment finding actually shows. Three levels, and the top one is narrower than it first looks: * - * `config` the project DECLARES a deployment (`vercel.json`, `wrangler.toml`, `_worker.js`) - * `provider-directory` a provider-specific function directory holding real source + * `runtime-entry` code that SERVES: a worker entry (`_worker.js`), or a provider function + * directory holding real source + * `deployment-config` the project deploys to a platform (`vercel.json`, `netlify.toml`, + * `wrangler.toml`) — which says nothing about whether anything serves requests. + * A static site on Netlify has a `netlify.toml` and no server at all. * `layout` an ordinary application folder that MIGHT hold functions (`api/`, `functions/`) + * + * Only `runtime-entry` supports concluding a server runtime exists. The other two are still reported and + * still rule out a confident "static" reading, because a project that deploys somewhere, or keeps an `api/` + * folder, is not one this analysis can call server-free. */ -export type DeploymentEvidence = 'config' | 'provider-directory' | 'layout'; +export type DeploymentEvidence = 'runtime-entry' | 'deployment-config' | 'layout'; export interface DeploymentShape { /** Which shape was recognized, e.g. `netlify-functions`. */ @@ -272,15 +279,9 @@ export interface DeploymentShape { /** 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. + * What the finding shows — see `DeploymentEvidence`. Only `runtime-entry` supports concluding that a + * server runtime exists; `deployment-config` and `layout` are reported, and rule out a confident + * "static" reading, without proving anything serves. */ evidence: DeploymentEvidence; } @@ -289,13 +290,17 @@ export interface DeploymentShape { export interface SurfaceSignal { /** * What kind of signal this is: - * `endpoint` a recognized entry point was parsed - * `deployment-artifact` a `config` or `provider-directory` deployment shape - * `ambiguous-layout` a `layout` shape — present, and not sufficient for a runtime conclusion - * `server-dependency` a server framework in the manifest - * `static-generator` a static build tool positively identified + * `endpoint` a recognized entry point was parsed + * `runtime-entry` an artifact that serves: a worker entry, or a provider function directory + * `deployment-config` a platform config — the project deploys, which is not the same as serving + * `ambiguous-layout` a `layout` shape — present, and not sufficient for a runtime conclusion + * `server-dependency` a server framework, or an SSR companion, in the manifest + * `static-generator` a static build tool positively identified + * + * Only `endpoint` and `runtime-entry` support a server-runtime conclusion. `deployment-config` and + * `ambiguous-layout` rule out a confident static reading without proving anything serves. */ - signal: 'endpoint' | 'deployment-artifact' | 'ambiguous-layout' | 'server-dependency' | 'static-generator'; + signal: 'endpoint' | 'runtime-entry' | 'deployment-config' | 'ambiguous-layout' | 'server-dependency' | 'static-generator'; /** The thing that produced the signal — a dependency name, a file, a count. */ source: string; } @@ -307,7 +312,9 @@ export interface SurfaceSignal { * request guard, so its advisories are dependency and bundle hygiene rather than request-path risk. * * `static-build-detected` requires a static generator to be NAMED, and is blocked by any deployment shape - * (including an ambiguous `layout` one) or any server-framework dependency. It is still not deployment + * (a platform config or an ambiguous `layout` folder included) or any server-framework dependency. A + * deployment config never produces `server-runtime-detected` on its own: a static site on Netlify has a + * `netlify.toml` and no server at all. It is still not deployment * attestation: it describes the source, not what is deployed, and a function added at the platform level * would not appear here. `unknown` is the honest answer for an unrecognised stack and must never be read as * "no server side" — an unparsed framework produces no endpoints, and entry-point recognition has no diff --git a/tests/map/deployment-shapes.test.ts b/tests/map/deployment-shapes.test.ts index d895843..6b38011 100644 --- a/tests/map/deployment-shapes.test.ts +++ b/tests/map/deployment-shapes.test.ts @@ -150,8 +150,9 @@ describe('the map carries the evidence, not just the label', () => { 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'); + // but as `layout`, so a classifier cannot read a server runtime out of it alone. And `wrangler.toml` + // is `deployment-config`: the project deploys to a platform, which is not the same as serving. + expect(byShape['cloudflare-workers']).toBe('deployment-config'); expect(byShape['root-api-directory']).toBe('layout'); }); @@ -168,7 +169,7 @@ 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('must not on its own be read as a server runtime'); + expect(note).toContain('Neither may on its own be read as a server runtime'); }); it('refuses an artifact that resolves outside the project', () => { diff --git a/tests/map/server-surface.test.ts b/tests/map/server-surface.test.ts index dfc2fc6..1d94b01 100644 --- a/tests/map/server-surface.test.ts +++ b/tests/map/server-surface.test.ts @@ -52,9 +52,9 @@ describe('a server runtime, when something positively shows one', () => { })).toBe('server-runtime-detected'); }); - it('reports a declared deployment even with no endpoint parsed', async () => { - // The case the evidence layer exists for: a handler this analysis cannot read still deploys. The - // artifact says so, and that outweighs an empty endpoint list. + it('reports function source with no endpoint parsed as a runtime', async () => { + // The case the evidence layer exists for: a handler this analysis cannot read still serves. The source + // in a provider function directory says so, and that outweighs an empty endpoint list. expect(await stateOf({ ...VITE_APP, 'netlify.toml': '[build]\n publish = "dist"', @@ -62,17 +62,24 @@ describe('a server runtime, when something positively shows one', () => { })).toBe('server-runtime-detected'); }); + it('reports a worker entry as a runtime, since that file IS the server', async () => { + expect(await stateOf({ + ...VITE_APP, + '_worker.js': 'export default { fetch: () => new Response("ok") }', + })).toBe('server-runtime-detected'); + }); + it('carries the evidence that produced the state', async () => { const map = await mapOf({ ...VITE_APP, - 'wrangler.toml': 'name = "app"', + 'supabase/functions/notify/index.ts': 'Deno.serve(() => new Response("ok"))', }); // A consumer has to be able to show WHY, not just display a badge. expect(map.serverSurface?.state).toBe('server-runtime-detected'); - expect(map.serverSurface?.evidence.map((e) => e.signal)).toContain('deployment-artifact'); - expect(map.serverSurface?.evidence.find((e) => e.signal === 'deployment-artifact')?.source) - .toContain('wrangler.toml'); + expect(map.serverSurface?.evidence.map((e) => e.signal)).toContain('runtime-entry'); + expect(map.serverSurface?.evidence.find((e) => e.signal === 'runtime-entry')?.source) + .toContain('supabase/functions'); }); }); @@ -86,8 +93,34 @@ describe('a static build, only when one is named', () => { .toBe('static-build-detected'); expect(await stateOf({ 'package.json': JSON.stringify({ dependencies: { gatsby: '5' } }) })) .toBe('static-build-detected'); - expect(await stateOf({ 'package.json': JSON.stringify({ devDependencies: { '@sveltejs/adapter-static': '3' } }) })) - .toBe('static-build-detected'); + }); + + it('reads a real static SvelteKit project, which ships kit AND the adapter', async () => { + // The package set people actually have. Treating `@sveltejs/kit` as an unconditional server dependency + // made every genuine static SvelteKit app permanently `unknown`, and the test that appeared to cover + // this installed the adapter with no kit — a combination nobody ships. + expect(await stateOf({ + 'package.json': JSON.stringify({ + devDependencies: { '@sveltejs/kit': '2', '@sveltejs/adapter-static': '3', vite: '5' }, + }), + })).toBe('static-build-detected'); + + // And kit without the static adapter stays undecided, because then it is a server framework. + expect(await stateOf({ + 'package.json': JSON.stringify({ devDependencies: { '@sveltejs/kit': '2', '@sveltejs/adapter-node': '5', vite: '5' } }), + })).toBe('unknown'); + }); + + it('does not read Vite as static when an SSR companion is installed', async () => { + // Vite builds client-only apps and underpins several server frameworks, so `vite` alone is not a + // static build. A companion that adds SSR vetoes the reading — conservative in the direction that + // matters, since this state means "no request-path protection needed". + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { vike: '0.4' }, devDependencies: { vite: '5' } }), + })).toBe('unknown'); + expect(await stateOf({ + 'package.json': JSON.stringify({ devDependencies: { vite: '5', 'vite-plugin-ssr': '0.4' } }), + })).toBe('unknown'); }); it('reads a Next project as static only when it exports statically', async () => { @@ -103,6 +136,47 @@ describe('a static build, only when one is named', () => { })).toBe('static-build-detected'); }); + it('ignores output: export when it is only a comment, a string, or dead code', async () => { + // A regex over the config text accepted all three, and each would have reclassified an ordinary + // server-mode Next app as static — the direction that loses protection. The config is parsed instead: + // comments never reach the AST, a string literal is one token, and only a value reachable from the + // module's export counts. + const commented = await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': ` + // output: 'export' <- we tried this and reverted + /* const old = { output: 'export' }; */ + module.exports = { reactStrictMode: true }; + `, + }); + expect(commented, 'a commented-out setting is not configuration').toBe('unknown'); + + const stringified = await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': `module.exports = { env: { NOTE: "output: 'export' is not set here" } };`, + }); + expect(stringified, 'the words inside a string are not a setting').toBe('unknown'); + + const deadCode = await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': ` + const staticExample = { output: 'export' }; + module.exports = { reactStrictMode: true }; + `, + }); + expect(deadCode, 'an object nobody exports is not this project’s config').toBe('unknown'); + + // And the real thing still reads, including through a plugin wrapper and a named variable. + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.mjs': `const nextConfig = { output: 'export' };\nexport default nextConfig;`, + })).toBe('static-build-detected'); + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': `module.exports = withPlugins({ output: 'export' });`, + })).toBe('static-build-detected'); + }); + it('does not list a static generator for a Next app that serves', async () => { // Two independent rules keep plain `next` out of `static-build-detected`: it is not counted as a static // generator, and it IS counted as a server dependency. Only the second decides the state, so mutating @@ -145,6 +219,30 @@ describe('a static build, only when one is named', () => { }); describe('unknown, which is most of the interesting cases', () => { + it('does not read a platform config alone as a server runtime', async () => { + // The common shape this gets wrong: a static site deployed to Netlify has a `netlify.toml` and no + // server whatsoever. Same for a static Vercel project and a Pages project serving only assets. The + // config establishes "deploys somewhere", which is not "serves requests". + for (const config of [ + { 'netlify.toml': '[build]\n publish = "dist"' }, + { 'vercel.json': '{"cleanUrls": true}' }, + { 'wrangler.toml': 'name = "app"\npages_build_output_dir = "dist"' }, + ]) { + expect(await stateOf({ ...VITE_APP, ...config }), JSON.stringify(config)).toBe('unknown'); + } + }); + + it('keeps the config visible as evidence, and still refuses the static claim', async () => { + // Both halves: the config is why this is not `static-build-detected`, and a consumer needs to see it — + // but it is not promoted to a runtime either. `unknown` with a stated reason. + const map = await mapOf({ ...VITE_APP, 'netlify.toml': '[build]\n publish = "dist"' }); + const signals = map.serverSurface?.evidence ?? []; + + expect(map.serverSurface?.state).toBe('unknown'); + expect(signals.map((e) => e.signal)).toContain('deployment-config'); + expect(signals.map((e) => e.signal)).toContain('static-generator'); + }); + it('refuses a static claim when a server framework is installed but no endpoint parsed', async () => { // The defect this rule prevents: an unparsed framework produces no endpoints, which looks exactly like // a static app. `express` in the manifest says otherwise, so the honest answer is that we do not know. From 04997bb64bd58a64974270d3bdb9ebcd6bdd95ff Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 12:39:08 +0200 Subject: [PATCH 3/4] Require a deployable function, and follow aliases without recursing forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Supabase shared code is no longer a runtime.** Any source one level under `supabase/functions` counted, but the platform's own convention puts code shared BETWEEN functions in `supabase/functions/_shared/` and does not deploy it — so a project with that directory full of TypeScript and nothing else was reported as serving requests. This is the signal the product messaging leans on, which makes a false positive here a wrong statement to a customer. The check now requires the layout Supabase actually deploys: a child directory not starting with `_`, holding an `index.*` file. A loose file directly under `functions/` no longer qualifies either, because that is not a deployable function. **Alias following is cycle-safe.** `const a = b; const b = a; export default a` recursed until the stack gave out. The surrounding catch turned that into a conservative `unknown`, but a malformed config should be an ordinary answer rather than an exception used as control flow — and a thrown RangeError discards any signal found before it. A visited set of names ends the walk; a legitimate alias chain still resolves, which is what the new test pins. Mutation testing found my test data too weak before it found anything wrong with the code, for the second time on this branch: the first `_shared` case used `cors.ts`, which the `index.*` requirement already rejected, so it passed without exercising the underscore rule at all. `_shared/index.ts` — an ordinary barrel file, and the layout that makes the rule matter — is the case that does. Full suite 1186 passed. Mutation-checked: accepting any source under `functions/`, dropping the underscore skip, and breaking alias following each fail their own assertions and nothing else. --- src/map/sources.ts | 45 +++++++++++++++++++++++++++-- src/map/surface.ts | 12 +++++++- tests/map/deployment-shapes.test.ts | 31 ++++++++++++++++++++ tests/map/server-surface.test.ts | 17 +++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/src/map/sources.ts b/src/map/sources.ts index b182e8f..4d1d8a8 100644 --- a/src/map/sources.ts +++ b/src/map/sources.ts @@ -146,7 +146,23 @@ export function isInside(candidate: string, boundary: string): boolean { // // `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 (`/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: 'deployment-config', files: ['vercel.json'] }, @@ -157,7 +173,7 @@ const DEPLOYMENT_SHAPES: Array<{ shape: string; evidence: DeploymentEvidence; fi // 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'] }, + { 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. @@ -218,7 +234,8 @@ export function detectDeploymentShapes(cwd: string, opts: DeploymentScanOptions // 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)) { + const qualifies = candidate.requiresFunctionEntry ? holdsFunctionEntry(full) : holdsSourceFile(full); + if (statSync(full).isDirectory() && inProject(full) && qualifies) { found.push({ shape: candidate.shape, source: dir, evidence: candidate.evidence }); break; } @@ -229,6 +246,28 @@ export function detectDeploymentShapes(cwd: string, opts: DeploymentScanOptions 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. + * + * 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. * diff --git a/src/map/surface.ts b/src/map/surface.ts index 0a0bdf7..7b6a651 100644 --- a/src/map/surface.ts +++ b/src/map/surface.ts @@ -143,9 +143,17 @@ function nextExportsStatically(cwd: string, manifest: Manifest, ts: TsModule | u return null; } -/** `output: 'export'` on the object this module actually exports. */ +/** + * `output: 'export'` on the object this module actually exports. + * + * Alias following is cycle-safe by construction. `const a = b; const b = a; export default a` would + * otherwise recurse until the stack gave out, and while the surrounding catch turns that into a + * conservative `unknown`, a malformed config should be an ordinary answer rather than an exception used as + * control flow — a thrown RangeError also discards any signal found before it. + */ function exportedConfigIsStatic(sf: any, ts: TsModule): boolean { const objects: any[] = []; + const followed = new Set(); const collectFrom = (expr: any): void => { if (!expr) return; @@ -155,6 +163,8 @@ function exportedConfigIsStatic(sf: any, ts: TsModule): boolean { // `const nextConfig = {...}; export default nextConfig` if (ts.isIdentifier(expr)) { const name = expr.text; + if (followed.has(name)) return; // already resolved, or part of a cycle + followed.add(name); const visit = (node: any): void => { if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) { collectFrom(node.initializer); diff --git a/tests/map/deployment-shapes.test.ts b/tests/map/deployment-shapes.test.ts index 6b38011..1609806 100644 --- a/tests/map/deployment-shapes.test.ts +++ b/tests/map/deployment-shapes.test.ts @@ -94,6 +94,37 @@ describe('what it refuses to claim', () => { expect(detectDeploymentShapes(dir)).toEqual([]); }); + it('ignores Supabase shared code that no function deploys', () => { + // `supabase/functions/_shared/` is the platform's own convention for code shared BETWEEN functions, + // and it is not deployed. A project can have it full of TypeScript and serve nothing, so counting it + // would report a runtime — and this is the signal the product messaging leans on. + expect(shapesOf({ + 'supabase/functions/_shared/cors.ts': 'export const cors = {};', + 'supabase/functions/_shared/db.ts': 'export const client = null;', + })).toEqual([]); + + // The layout that makes the underscore rule matter: `_shared/index.ts` is an ordinary barrel file, and + // it satisfies the per-function entry test on name alone. Without the underscore skip, shared code with + // a barrel would report a deployed function. (My first version of this test used `_shared/cors.ts`, + // which the index check already rejected — so it passed without exercising the rule at all.) + expect(shapesOf({ + 'supabase/functions/_shared/index.ts': "export * from './cors.ts';", + })).toEqual([]); + + // A real function alongside it is still found. + expect(shapesOf({ + 'supabase/functions/_shared/cors.ts': 'export const cors = {};', + 'supabase/functions/notify/index.ts': 'Deno.serve(() => new Response("ok"))', + })).toContain('supabase-functions'); + }); + + it('requires the per-function layout for Supabase, not just any source', () => { + // Supabase deploys `/index.ts`. A loose file directly under `functions/` is not a deployable + // function, so it is not evidence of one. + expect(shapesOf({ 'supabase/functions/helpers.ts': 'export const x = 1;' })).toEqual([]); + expect(shapesOf({ 'supabase/functions/notify/handler.ts': 'export default () => {}' })).toEqual([]); + }); + it('ignores a directory holding no source file', () => { expect(shapesOf({ 'api/README.md': '# planned', 'api/notes.txt': 'later' })).toEqual([]); }); diff --git a/tests/map/server-surface.test.ts b/tests/map/server-surface.test.ts index 1d94b01..f83afa4 100644 --- a/tests/map/server-surface.test.ts +++ b/tests/map/server-surface.test.ts @@ -177,6 +177,23 @@ describe('a static build, only when one is named', () => { })).toBe('static-build-detected'); }); + it('follows an alias chain to the real config, and survives a cyclic one', async () => { + // Chained aliases are ordinary in real configs, so the traversal has to follow them... + expect(await stateOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.mjs': `const base = { output: 'export' };\nconst config = base;\nexport default config;`, + })).toBe('static-build-detected'); + + // ...and a malformed one must come back as an ordinary answer rather than an exception used as control + // flow. Recursing until the stack gives out also discards any signal found before it. + const cyclic = await mapOf({ + 'package.json': JSON.stringify({ dependencies: { next: '14' } }), + 'next.config.js': `const a = b;\nconst b = a;\nmodule.exports = a;`, + }); + expect(cyclic.serverSurface?.state).toBe('unknown'); + expect(cyclic.coverage.notes.some((n) => n.includes('serverSurface'))).toBe(true); + }); + it('does not list a static generator for a Next app that serves', async () => { // Two independent rules keep plain `next` out of `static-build-detected`: it is not counted as a static // generator, and it IS counted as a server dependency. Only the second decides the state, so mutating From c4213809908dbdb361b7995734bb8a10e0074be1 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 19 Aug 2026 12:45:07 +0200 Subject: [PATCH 4/4] Settle containment before reading a directory, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boundary check ran, but it ran too late. `qualifies` was evaluated as its own statement before the `isDirectory() && inProject() && qualifies` test, so a symlinked `api/` or `functions/` pointing outside the project was walked on every scan and the result discarded. Nothing showed in the output, which is what made it survive review of the previous fix — and "we look and throw it away" is not the guarantee. It also contradicted the helper's own docblock, which says its caller has already confirmed containment. Containment is now settled first, and the contents are only inspected for a directory that passed. The control took two attempts, and the first one was worthless in a way worth recording. It filtered recorded reads by the symlink's TARGET path — but the code reads the link path (`/api`) and the kernel resolves it, so the target never appears in a read call and the assertion would have passed against the unfixed code too. It now asserts on the link path, and the `followOutside` half proves the control can observe a read at all: a control that cannot see the behaviour it forbids is not a control. Reads are recorded through a delegating `node:fs` mock, since an ESM module namespace cannot be spied and the property under test is not visible in the return value. Full suite 1187 passed. Mutation-checked by restoring the original ordering: the new control fails, and nothing else does. --- src/map/sources.ts | 28 +++++++++++------- tests/map/deployment-shapes.test.ts | 46 ++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/map/sources.ts b/src/map/sources.ts index 4d1d8a8..d653e1f 100644 --- a/src/map/sources.ts +++ b/src/map/sources.ts @@ -229,17 +229,23 @@ 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. - const qualifies = candidate.requiresFunctionEntry ? holdsFunctionEntry(full) : holdsSourceFile(full); - if (statSync(full).isDirectory() && inProject(full) && qualifies) { - 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; + } } } @@ -250,6 +256,8 @@ export function detectDeploymentShapes(cwd: string, opts: DeploymentScanOptions * 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. */ diff --git a/tests/map/deployment-shapes.test.ts b/tests/map/deployment-shapes.test.ts index 1609806..895dc40 100644 --- a/tests/map/deployment-shapes.test.ts +++ b/tests/map/deployment-shapes.test.ts @@ -1,5 +1,22 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; + +// Records every directory read, delegating to the real implementation so behaviour is unchanged. A spy on +// the module namespace is not possible under ESM, and the property being asserted — that an escaping link is +// never TRAVERSED, as opposed to traversed and discarded — is not observable from the return value. +const { directoryReads } = vi.hoisted(() => ({ directoryReads: [] as string[] })); +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + readdirSync: (path: unknown, options?: unknown) => { + directoryReads.push(String(path)); + + return (actual.readdirSync as (p: unknown, o?: unknown) => unknown)(path, options); + }, + }; +}); import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { detectDeploymentShapes } from '../../src/map/sources.js'; @@ -217,6 +234,33 @@ describe('the map carries the evidence, not just the label', () => { .toContain('root-api-directory'); }); + it('never READS an escaping directory, not merely discards what it found there', () => { + // The distinction that matters: refusing to report a finding is not the same guarantee as refusing to + // look. Evaluating the contents check before the boundary check meant this analysis walked a tree + // outside the project on every scan and then threw the result away — invisible in the output, and + // exactly the behaviour the boundary exists to prevent. + const outside = project({ 'handler.ts': 'export default () => new Response("ok")' }); + const dir = project({ 'package.json': '{}' }); + symlinkSync(outside, join(dir, 'api'), 'dir'); + + // Asserted on the LINK path, which is what gets read: the code walks `/api`, and the kernel + // resolves it to the external tree. A first version of this control filtered on the target path, which + // never appears in a read call — so it would have reported success against the unfixed code too. + const linkPath = join(dir, 'api'); + + directoryReads.length = 0; + expect(detectDeploymentShapes(dir)).toEqual([]); + expect(directoryReads.filter((target) => target.startsWith(linkPath)), + 'an escaping directory must not be read at all').toEqual([]); + + // With the opt-out it IS read. This half proves the control can see a read, so the empty list above + // means something — a control that cannot observe the behaviour it forbids is not a control. + directoryReads.length = 0; + expect(detectDeploymentShapes(dir, { followOutside: true }).map((s) => s.shape)) + .toContain('root-api-directory'); + expect(directoryReads.some((target) => target.startsWith(linkPath))).toBe(true); + }); + it('refuses a config file that resolves outside the project', () => { const outside = project({ 'vercel.json': '{}' }); const dir = project({ 'package.json': '{}' });