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