Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

263 linesCodeBlameRaw
1/**
2 * What a project is (an app, a library, a tool, docs or other) and where
3 * it runs (hosted on g1t.page, deploying itself to hosting of its own, or
4 * nowhere). A person can say either in its
5 * settings; left to detection, it is worked out here from what the project
6 * has: Deployments being on, packages its repository publishes, and the
7 * files at its root. The functions are pure so the rules can be tested on
8 * their own; the service reads the files and keeps the detection by commit.
9 */
10
11import type { KindReason, ProjectEcosystem, ProjectKind, ProjectRuns, ProjectSetting } from "@g1t/contracts";
12
13/** As contracts' PROJECT_KINDS; repeated so this module's tests run without the package. */
14const PROJECT_KINDS: readonly ProjectKind[] = ["app", "library", "tool", "docs", "other"];
15
16/** The manifests whose contents detection reads, when they are at the root. */
17export const MANIFESTS = ["composer.json", "Cargo.toml", "go.mod", "pyproject.toml", "package.json"] as const;
18/** At most this many root `.go` files are read to find `package main`. */
19export const GO_FILES_READ = 3;
20
21/** What detection is given: the project's root, and what was read from it. */
22export type RootFiles = {
23 /** Names at the project's root; directories end in `/`. */
24 entries: string[];
25 /** Contents of the manifests and root `.go` files read, by name. */
26 text: Record<string, string>;
27 /** Names in `src/`, when the root has a Cargo.toml. */
28 src?: string[];
29 /** Names in `public/`, when the root has a composer.json. */
30 public?: string[];
31};
32
33/** What the files say: a kind with why, or null when nothing says either way. */
34export type Detection = { kind: ProjectKind | null; detail: string; ecosystem: ProjectEcosystem | null };
35
36/** Root `.go` files detection reads, tests left out. */
37export function goFilesToRead(entries: string[]): string[] {
38 return entries.filter((name) => name.endsWith(".go") && !name.endsWith("_test.go")).slice(0, GO_FILES_READ);
39}
40
41const has = (files: RootFiles, name: string) => files.entries.includes(name);
42
43/** Frameworks that make a package.json an app. Hosting a server alone does not: a start script does. */
44const JS_APP_FRAMEWORKS = [
45 "next",
46 "astro",
47 "nuxt",
48 "@remix-run/dev",
49 "@remix-run/node",
50 "@react-router/dev",
51 "@sveltejs/kit",
52 "gatsby",
53 "@angular/core",
54 "expo",
55 "react-scripts",
56];
57/** Documentation generators' configs: a root with one is documentation. */
58const DOCS_CONFIGS = ["mkdocs.yml", "mkdocs.yaml", "book.toml", "docusaurus.config.js", "docusaurus.config.ts", "docusaurus.config.mjs", "antora.yml"];
59/** Python frameworks that make a pyproject an app. */
60const PY_APP_FRAMEWORKS = ["django", "flask", "fastapi", "streamlit", "gradio", "starlette", "uvicorn"];
61
62function composer(files: RootFiles, text: string): Detection {
63 const ecosystem = "composer";
64 let json: { type?: unknown; autoload?: unknown };
65 try {
66 json = JSON.parse(text);
67 } catch {
68 return { kind: null, detail: "composer.json could not be read.", ecosystem };
69 }
70 const type = typeof json.type === "string" ? json.type : null;
71 if (type === "project") return { kind: "app", detail: 'composer.json says "type": "project".', ecosystem };
72 if (type) return { kind: "library", detail: `composer.json says "type": "${type}".`, ecosystem };
73 const servesIndex = files.public?.includes("index.php") || has(files, "index.php");
74 if (json.autoload && !servesIndex) {
75 return { kind: "library", detail: "composer.json has autoload and no public/index.php.", ecosystem };
76 }
77 if (servesIndex) return { kind: "app", detail: "It has a composer.json and an index.php to serve.", ecosystem };
78 return { kind: null, detail: "composer.json has no type or autoload.", ecosystem };
79}
80
81function cargo(files: RootFiles, text: string): Detection {
82 const ecosystem = "cargo";
83 const lib = /^\s*\[lib\]/m.test(text) || !!files.src?.includes("lib.rs");
84 const bin = /^\s*\[\[bin\]\]/m.test(text) || !!files.src?.includes("main.rs");
85 if (bin) return { kind: "app", detail: "Cargo.toml builds a binary.", ecosystem };
86 if (lib) return { kind: "library", detail: "Cargo.toml builds a library and no binary.", ecosystem };
87 return { kind: null, detail: "Cargo.toml builds neither a library nor a binary here.", ecosystem };
88}
89
90function go(files: RootFiles): Detection {
91 const ecosystem = "go";
92 const sources = goFilesToRead(files.entries);
93 const main = sources.find((name) => /^\s*package\s+main\b/m.test(files.text[name] ?? ""));
94 if (main) return { kind: "app", detail: `${main} at the root is package main.`, ecosystem };
95 if (sources.length > 0) return { kind: "library", detail: "go.mod, with no package main at the root.", ecosystem };
96 // A module whose code is all in directories: commands live in cmd/.
97 if (has(files, "cmd/")) return { kind: null, detail: "go.mod, with commands in cmd/.", ecosystem };
98 return { kind: "library", detail: "go.mod, with no package main at the root.", ecosystem };
99}
100
101function python(text: string): Detection {
102 const ecosystem = "python";
103 const backend = /^\s*build-backend\s*=/m.test(text) || /^\s*\[tool\.poetry\]/m.test(text);
104 const framework = PY_APP_FRAMEWORKS.find((name) => new RegExp(`["'\\s]${name}(?![\\w-])`, "i").test(text));
105 if (framework) return { kind: "app", detail: `pyproject.toml depends on ${framework}.`, ecosystem };
106 if (backend) return { kind: "library", detail: "pyproject.toml has a build backend and no app framework.", ecosystem };
107 return { kind: null, detail: "pyproject.toml has no build backend.", ecosystem };
108}
109
110function npm(files: RootFiles, text: string): Detection {
111 const ecosystem = "npm";
112 let json: {
113 scripts?: Record<string, unknown>;
114 dependencies?: Record<string, unknown>;
115 devDependencies?: Record<string, unknown>;
116 main?: unknown;
117 module?: unknown;
118 exports?: unknown;
119 files?: unknown;
120 bin?: unknown;
121 };
122 try {
123 json = JSON.parse(text);
124 } catch {
125 return { kind: null, detail: "package.json could not be read.", ecosystem };
126 }
127 const script = ["start", "dev"].find((name) => typeof json.scripts?.[name] === "string");
128 if (script) return { kind: "app", detail: `package.json has a ${script} script.`, ecosystem };
129 const deps = { ...json.devDependencies, ...json.dependencies };
130 const framework = JS_APP_FRAMEWORKS.find((name) => name in deps);
131 if (framework) return { kind: "app", detail: `package.json depends on ${framework}.`, ecosystem };
132 // Vite builds libraries too; with an index.html at the root it is a site.
133 if ("vite" in deps && has(files, "index.html")) return { kind: "app", detail: "A Vite site, with index.html at the root.", ecosystem };
134 // A command to install and nothing to import: a tool.
135 if (json.bin != null && json.main == null && json.module == null && json.exports == null) {
136 return { kind: "tool", detail: 'package.json has "bin" and nothing to import.', ecosystem };
137 }
138 const entry = ["exports", "main", "module", "files", "bin"].find((key) => json[key as keyof typeof json] != null);
139 if (entry) return { kind: "library", detail: `package.json has "${entry}" and no start or dev script.`, ecosystem };
140 return { kind: null, detail: "package.json has no entry point or start script.", ecosystem };
141}
142
143/**
144 * What a project's root says it is. A Workers config or a root index.html
145 * is something to serve. Otherwise the first manifest present that says
146 * either way decides, the language's own before package.json, which many
147 * projects carry only for tooling.
148 */
149export function detectKind(files: RootFiles): Detection {
150 const workers = ["wrangler.toml", "wrangler.json", "wrangler.jsonc"].find((name) => has(files, name));
151 if (workers) return { kind: "app", detail: `${workers} at the root.`, ecosystem: null };
152 const docs = DOCS_CONFIGS.find((name) => has(files, name));
153 if (docs) return { kind: "docs", detail: `${docs} at the root builds documentation.`, ecosystem: null };
154 let first: Detection | null = null;
155 for (const name of MANIFESTS) {
156 if (!has(files, name)) continue;
157 const text = files.text[name] ?? "";
158 const found =
159 name === "composer.json"
160 ? composer(files, text)
161 : name === "Cargo.toml"
162 ? cargo(files, text)
163 : name === "go.mod"
164 ? go(files)
165 : name === "pyproject.toml"
166 ? python(text)
167 : npm(files, text);
168 if (found.kind) return found;
169 first ??= found;
170 }
171 if (has(files, "index.html")) return { kind: "app", detail: "index.html at the root.", ecosystem: first?.ecosystem ?? null };
172 return first ?? { kind: null, detail: "No manifest at the root says what it is.", ecosystem: null };
173}
174
175/** What resolution is given, from the project's row. */
176export type KindFacts = {
177 /** What a person set; null parts are left to detection. */
178 setting: ProjectSetting;
179 /** Whether Deployments are on for it; null while unknown. */
180 deploymentsOn: boolean | null;
181 /** Whether it has deployed production itself: a workflow's or the API's deployment, not g1t.page's, succeeded. */
182 reportsProduction: boolean;
183 /** A package its repository publishes, other than a container image, as `Composer package psr/log`; null for none. */
184 linkedPackage: string | null;
185 /** What its files say; null before they have been read. */
186 detected: { kind: ProjectKind | null; detail: string } | null;
187};
188
189/** How a kind is named in a sentence: `Set in its settings: a library.` */
190export const KIND_PHRASE: Record<ProjectKind, string> = {
191 app: "an app",
192 library: "a library",
193 tool: "a tool",
194 docs: "documentation",
195 other: "not an app, a library, a tool or docs",
196};
197
198/** Kinds that can run somewhere: an app, and docs published as a site. */
199export const RUNNABLE: readonly ProjectKind[] = ["app", "docs"];
200
201/**
202 * What a project is, and where it runs. The setting wins, and where it
203 * runs being set makes it an app. Left to detection: Deployments being on
204 * makes it an app; then a package its repository publishes, or files that
205 * say so, decide; anything else is an app, so nothing that deploys loses
206 * its production card to a guess.
207 *
208 * Only an app or docs runs anywhere: where it is set to; on g1t.page
209 * while Deployments are on; deploying itself once it has deployed
210 * production itself; and otherwise nobody has said (null).
211 */
212export function resolveKind(facts: KindFacts): { kind: ProjectKind; reason: KindReason; runs: ProjectRuns | null } {
213 const { kind, reason } = kindOf(facts);
214 const runs = RUNNABLE.includes(kind) ? (facts.setting.runs ?? (facts.deploymentsOn ? "g1t" : facts.reportsProduction ? "elsewhere" : null)) : null;
215 return { kind, reason, runs };
216}
217
218function kindOf(facts: KindFacts): { kind: ProjectKind; reason: KindReason } {
219 const { setting } = facts;
220 if (setting.kind) return { kind: setting.kind, reason: { by: "set", detail: `Set in its settings: ${KIND_PHRASE[setting.kind]}.` } };
221 if (setting.runs === "g1t") return { kind: "app", reason: { by: "set", detail: "Set in its settings: it is hosted on g1t.page." } };
222 if (setting.runs === "elsewhere") return { kind: "app", reason: { by: "set", detail: "Set in its settings: it deploys itself." } };
223 return detectedKind(facts);
224}
225
226/** What detection alone decides, whatever the setting is. */
227export function detectedKind(facts: Omit<KindFacts, "setting">): { kind: ProjectKind; reason: KindReason } {
228 if (facts.deploymentsOn) return { kind: "app", reason: { by: "deployments", detail: "Deployments are on for it." } };
229 if (facts.reportsProduction) return { kind: "app", reason: { by: "deployments", detail: "It deploys production itself." } };
230 if (facts.linkedPackage) {
231 return { kind: "library", reason: { by: "packages", detail: `Its repository publishes the ${facts.linkedPackage}.` } };
232 }
233 if (facts.detected?.kind) return { kind: facts.detected.kind, reason: { by: "files", detail: facts.detected.detail } };
234 return { kind: "app", reason: { by: "default", detail: "Nothing in it says what it is, so it is taken to be an app." } };
235}
236
237/** A stored or submitted kind; null (left to detection) for anything else. */
238export function kindSetting(value: unknown): ProjectKind | null {
239 return PROJECT_KINDS.includes(value as ProjectKind) ? (value as ProjectKind) : null;
240}
241
242/** A stored or submitted place it runs; null (left to Deployments) for anything else. */
243export function runsSetting(value: unknown): ProjectRuns | null {
244 return value === "g1t" || value === "elsewhere" ? value : null;
245}
246
247/**
248 * The setting after a change, where `auto` leaves a part to detection. A
249 * kind that never runs clears where it runs; where it runs being set on
250 * something that never runs makes it an app.
251 */
252export function nextSetting(current: ProjectSetting, change: { kind?: ProjectKind | "auto"; runs?: ProjectRuns | "auto" }): ProjectSetting {
253 let kind = change.kind === undefined ? current.kind : kindSetting(change.kind);
254 let runs = change.runs === undefined ? current.runs : runsSetting(change.runs);
255 if (change.kind !== undefined && kind && !RUNNABLE.includes(kind) && change.runs === undefined) runs = null;
256 if (runs && kind && !RUNNABLE.includes(kind)) kind = "app";
257 return { kind, runs };
258}
259
260/** Whether a setting stops the project running anywhere: Deployments are turned off first. */
261export function neverRuns(setting: ProjectSetting): boolean {
262 return setting.kind != null && !RUNNABLE.includes(setting.kind);
263}