g1t/services/projects/src/kind.ts

185 lines9,835 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

A project is an app or a library: libraries show their package and how to ship a release, not production1/**
2 * Whether a project is an app, which deploys, or a library (or a tool),
3 * which is published and installed. A person can say so in its settings;
4 * left on `auto`, it is worked out here from what the project has: Deployments
5 * being on, packages its repository publishes, and the manifests at its
6 * root. Both functions are pure so the rules can be tested on their own;
7 * the service reads the files and keeps the detection by commit.
8 */
9
10import type { DeploysSetting, KindReason, ProjectEcosystem, ProjectKind } from "@g1t/contracts";
11
12/** The manifests whose contents detection reads, when they are at the root. */
13export const MANIFESTS = ["composer.json", "Cargo.toml", "go.mod", "pyproject.toml", "package.json"] as const;
14/** At most this many root `.go` files are read to find `package main`. */
15export const GO_FILES_READ = 3;
16
17/** What detection is given: the project's root, and what was read from it. */
18export type RootFiles = {
19 /** Names at the project's root; directories end in `/`. */
20 entries: string[];
21 /** Contents of the manifests and root `.go` files read, by name. */
22 text: Record<string, string>;
23 /** Names in `src/`, when the root has a Cargo.toml. */
24 src?: string[];
25 /** Names in `public/`, when the root has a composer.json. */
26 public?: string[];
27};
28
29/** What the files say: a kind with why, or null when nothing says either way. */
30export type Detection = { kind: ProjectKind | null; detail: string; ecosystem: ProjectEcosystem | null };
31
32/** Root `.go` files detection reads, tests left out. */
33export function goFilesToRead(entries: string[]): string[] {
34 return entries.filter((name) => name.endsWith(".go") && !name.endsWith("_test.go")).slice(0, GO_FILES_READ);
35}
36
37const has = (files: RootFiles, name: string) => files.entries.includes(name);
38
39/** Frameworks that make a package.json an app. Hosting a server alone does not: a start script does. */
40const JS_APP_FRAMEWORKS = ["next", "astro", "nuxt", "@remix-run/dev", "@remix-run/node", "@react-router/dev", "@sveltejs/kit", "gatsby", "@angular/core", "expo", "react-scripts"];
41/** Python frameworks that make a pyproject an app. */
42const PY_APP_FRAMEWORKS = ["django", "flask", "fastapi", "streamlit", "gradio", "starlette", "uvicorn"];
43
44function composer(files: RootFiles, text: string): Detection {
45 const ecosystem = "composer";
46 let json: { type?: unknown; autoload?: unknown };
47 try {
48 json = JSON.parse(text);
49 } catch {
50 return { kind: null, detail: "composer.json could not be read.", ecosystem };
51 }
52 const type = typeof json.type === "string" ? json.type : null;
53 if (type === "project") return { kind: "app", detail: 'composer.json says "type": "project".', ecosystem };
54 if (type) return { kind: "library", detail: `composer.json says "type": "${type}".`, ecosystem };
55 const servesIndex = files.public?.includes("index.php") || has(files, "index.php");
56 if (json.autoload && !servesIndex) {
57 return { kind: "library", detail: "composer.json has autoload and no public/index.php.", ecosystem };
58 }
59 if (servesIndex) return { kind: "app", detail: "It has a composer.json and an index.php to serve.", ecosystem };
60 return { kind: null, detail: "composer.json has no type or autoload.", ecosystem };
61}
62
63function cargo(files: RootFiles, text: string): Detection {
64 const ecosystem = "cargo";
65 const lib = /^\s*\[lib\]/m.test(text) || !!files.src?.includes("lib.rs");
66 const bin = /^\s*\[\[bin\]\]/m.test(text) || !!files.src?.includes("main.rs");
67 if (bin) return { kind: "app", detail: "Cargo.toml builds a binary.", ecosystem };
68 if (lib) return { kind: "library", detail: "Cargo.toml builds a library and no binary.", ecosystem };
69 return { kind: null, detail: "Cargo.toml builds neither a library nor a binary here.", ecosystem };
70}
71
72function go(files: RootFiles): Detection {
73 const ecosystem = "go";
74 const sources = goFilesToRead(files.entries);
75 const main = sources.find((name) => /^\s*package\s+main\b/m.test(files.text[name] ?? ""));
76 if (main) return { kind: "app", detail: `${main} at the root is package main.`, ecosystem };
77 if (sources.length > 0) return { kind: "library", detail: "go.mod, with no package main at the root.", ecosystem };
78 // A module whose code is all in directories: commands live in cmd/.
79 if (has(files, "cmd/")) return { kind: null, detail: "go.mod, with commands in cmd/.", ecosystem };
80 return { kind: "library", detail: "go.mod, with no package main at the root.", ecosystem };
81}
82
83function python(text: string): Detection {
84 const ecosystem = "python";
85 const backend = /^\s*build-backend\s*=/m.test(text) || /^\s*\[tool\.poetry\]/m.test(text);
86 const framework = PY_APP_FRAMEWORKS.find((name) => new RegExp(`["'\\s]${name}(?![\\w-])`, "i").test(text));
87 if (framework) return { kind: "app", detail: `pyproject.toml depends on ${framework}.`, ecosystem };
88 if (backend) return { kind: "library", detail: "pyproject.toml has a build backend and no app framework.", ecosystem };
89 return { kind: null, detail: "pyproject.toml has no build backend.", ecosystem };
90}
91
92function npm(files: RootFiles, text: string): Detection {
93 const ecosystem = "npm";
94 let json: {
95 scripts?: Record<string, unknown>;
96 dependencies?: Record<string, unknown>;
97 devDependencies?: Record<string, unknown>;
98 main?: unknown;
99 module?: unknown;
100 exports?: unknown;
101 files?: unknown;
102 bin?: unknown;
103 };
104 try {
105 json = JSON.parse(text);
106 } catch {
107 return { kind: null, detail: "package.json could not be read.", ecosystem };
108 }
109 const script = ["start", "dev"].find((name) => typeof json.scripts?.[name] === "string");
110 if (script) return { kind: "app", detail: `package.json has a ${script} script.`, ecosystem };
111 const deps = { ...json.devDependencies, ...json.dependencies };
112 const framework = JS_APP_FRAMEWORKS.find((name) => name in deps);
113 if (framework) return { kind: "app", detail: `package.json depends on ${framework}.`, ecosystem };
114 // Vite builds libraries too; with an index.html at the root it is a site.
115 if ("vite" in deps && has(files, "index.html")) return { kind: "app", detail: "A Vite site, with index.html at the root.", ecosystem };
116 const entry = ["exports", "main", "module", "files", "bin"].find((key) => json[key as keyof typeof json] != null);
117 if (entry) return { kind: "library", detail: `package.json has "${entry}" and no start or dev script.`, ecosystem };
118 return { kind: null, detail: "package.json has no entry point or start script.", ecosystem };
119}
120
121/**
122 * What a project's root says it is. A Workers config or a root index.html
123 * is something to serve. Otherwise the first manifest present that says
124 * either way decides, the language's own before package.json, which many
125 * projects carry only for tooling.
126 */
127export function detectKind(files: RootFiles): Detection {
128 const workers = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"].find((name) => has(files, name));
129 if (workers) return { kind: "app", detail: `${workers} at the root.`, ecosystem: null };
130 let first: Detection | null = null;
131 for (const name of MANIFESTS) {
132 if (!has(files, name)) continue;
133 const text = files.text[name] ?? "";
134 const found =
135 name === "composer.json"
136 ? composer(files, text)
137 : name === "Cargo.toml"
138 ? cargo(files, text)
139 : name === "go.mod"
140 ? go(files)
141 : name === "pyproject.toml"
142 ? python(text)
143 : npm(files, text);
144 if (found.kind) return found;
145 first ??= found;
146 }
147 if (has(files, "index.html")) return { kind: "app", detail: "index.html at the root.", ecosystem: first?.ecosystem ?? null };
148 return first ?? { kind: null, detail: "No manifest at the root says what it is.", ecosystem: null };
149}
150
151/** What resolution is given, from the project's row. */
152export type KindFacts = {
153 deploys: DeploysSetting;
154 /** Whether Deployments are on for it; null while unknown. */
155 deploymentsOn: boolean | null;
156 /** A package its repository publishes, other than a container image, as `Composer package psr/log`; null for none. */
157 linkedPackage: string | null;
158 /** What its files say; null before they have been read. */
159 detected: { kind: ProjectKind | null; detail: string } | null;
160};
161
162/**
163 * The kind a project is. The setting wins. On `auto`: Deployments being on
164 * makes it an app; then a package its repository publishes, or files that
165 * say library, make it a library; anything else is an app, so nothing that
166 * deploys loses its production card to a guess.
167 */
168export function resolveKind(facts: KindFacts): { kind: ProjectKind; reason: KindReason } {
169 if (facts.deploys === "yes") return { kind: "app", reason: { by: "set", detail: "Set in its settings: it deploys." } };
170 if (facts.deploys === "no") return { kind: "library", reason: { by: "set", detail: "Set in its settings: it doesn't deploy." } };
171 if (facts.deploymentsOn) return { kind: "app", reason: { by: "deployments", detail: "Deployments are on for it." } };
172 if (facts.linkedPackage) {
173 return { kind: "library", reason: { by: "packages", detail: `Its repository publishes the ${facts.linkedPackage}.` } };
174 }
175 if (facts.detected?.kind === "library") return { kind: "library", reason: { by: "files", detail: facts.detected.detail } };
176 if (facts.detected?.kind === "app") return { kind: "app", reason: { by: "files", detail: facts.detected.detail } };
177 return { kind: "app", reason: { by: "default", detail: "Nothing in it says it is a library, so it is taken to deploy." } };
178}
179
180const SETTINGS: readonly DeploysSetting[] = ["auto", "yes", "no"];
181
182/** A stored or submitted setting, `auto` for anything else. */
183export function deploysSetting(value: unknown): DeploysSetting {
184 return SETTINGS.includes(value as DeploysSetting) ? (value as DeploysSetting) : "auto";
185}