peer-ai 1.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +403 -0
- package/dist/assess.d.ts +102 -0
- package/dist/assess.js +545 -0
- package/dist/check.d.ts +30 -0
- package/dist/check.js +253 -0
- package/dist/checks.d.ts +15 -0
- package/dist/checks.js +15 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +231 -0
- package/dist/detect.d.ts +42 -0
- package/dist/detect.js +459 -0
- package/dist/doctor.d.ts +36 -0
- package/dist/doctor.js +297 -0
- package/dist/document.d.ts +30 -0
- package/dist/document.js +72 -0
- package/dist/enforcers.d.ts +6 -0
- package/dist/enforcers.js +307 -0
- package/dist/feedback.d.ts +67 -0
- package/dist/feedback.js +209 -0
- package/dist/files.d.ts +1 -0
- package/dist/files.js +72 -0
- package/dist/init.d.ts +31 -0
- package/dist/init.js +158 -0
- package/dist/mcp.d.ts +13 -0
- package/dist/mcp.js +247 -0
- package/dist/package-info.d.ts +4 -0
- package/dist/package-info.js +6 -0
- package/dist/pipeline.d.ts +82 -0
- package/dist/pipeline.js +265 -0
- package/dist/prompter.d.ts +23 -0
- package/dist/prompter.js +56 -0
- package/dist/render.d.ts +58 -0
- package/dist/render.js +557 -0
- package/dist/report.d.ts +3 -0
- package/dist/report.js +93 -0
- package/dist/routing.d.ts +24 -0
- package/dist/routing.js +121 -0
- package/dist/ruff.d.ts +19 -0
- package/dist/ruff.js +64 -0
- package/dist/standards.d.ts +46 -0
- package/dist/standards.js +130 -0
- package/dist/state.d.ts +22 -0
- package/dist/state.js +56 -0
- package/dist/test-helpers.d.ts +16 -0
- package/dist/test-helpers.js +62 -0
- package/dist/work.d.ts +147 -0
- package/dist/work.js +357 -0
- package/package.json +45 -0
package/dist/detect.js
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// Reads a repository and works out what it can without asking: the project's name, whether
|
|
2
|
+
// it is new or existing, which AI tools are set up, the git host, its CI, and the parts it is
|
|
3
|
+
// made of, with where each one deploys. Everything found here is a proposal; `init` shows it
|
|
4
|
+
// to the user before writing anything, and nothing that isn't found is assumed.
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
|
+
import { CONFIG_FILE } from "peer-ai-workflow";
|
|
9
|
+
export { CONFIG_FILE };
|
|
10
|
+
const PROJECT_MARKERS = [
|
|
11
|
+
"package.json",
|
|
12
|
+
"pyproject.toml",
|
|
13
|
+
"requirements.txt",
|
|
14
|
+
"go.mod",
|
|
15
|
+
"Cargo.toml",
|
|
16
|
+
"pubspec.yaml",
|
|
17
|
+
"Gemfile",
|
|
18
|
+
"composer.json",
|
|
19
|
+
"pom.xml",
|
|
20
|
+
"build.gradle",
|
|
21
|
+
"build.gradle.kts",
|
|
22
|
+
"Package.swift",
|
|
23
|
+
"Podfile",
|
|
24
|
+
"mix.exs",
|
|
25
|
+
"src",
|
|
26
|
+
"app",
|
|
27
|
+
"lib",
|
|
28
|
+
];
|
|
29
|
+
const WORKSPACE_DIRS = ["apps", "packages", "services", "libs"];
|
|
30
|
+
const INFRA_DIRS = [
|
|
31
|
+
"infra",
|
|
32
|
+
"infrastructure",
|
|
33
|
+
"terraform",
|
|
34
|
+
"deploy",
|
|
35
|
+
"deployment",
|
|
36
|
+
"deployments",
|
|
37
|
+
"ops",
|
|
38
|
+
"devops",
|
|
39
|
+
"k8s",
|
|
40
|
+
"kubernetes",
|
|
41
|
+
"helm",
|
|
42
|
+
"charts",
|
|
43
|
+
"cdk",
|
|
44
|
+
"pulumi",
|
|
45
|
+
];
|
|
46
|
+
// Order matters: the first match decides the kind, so infrastructure, mobile and desktop
|
|
47
|
+
// frameworks come before the web frameworks they may be built with.
|
|
48
|
+
const NODE_FRAMEWORKS = [
|
|
49
|
+
["aws-cdk-lib", "aws-cdk", "infrastructure"],
|
|
50
|
+
["cdktf", "cdktf", "infrastructure"],
|
|
51
|
+
["@pulumi/pulumi", "pulumi", "infrastructure"],
|
|
52
|
+
["expo", "expo", "mobile"],
|
|
53
|
+
["react-native", "react-native", "mobile"],
|
|
54
|
+
["electron", "electron", "desktop"],
|
|
55
|
+
["@tauri-apps/api", "tauri", "desktop"],
|
|
56
|
+
["next", "next", "web"],
|
|
57
|
+
["nuxt", "nuxt", "web"],
|
|
58
|
+
["@sveltejs/kit", "sveltekit", "web"],
|
|
59
|
+
["@angular/core", "angular", "web"],
|
|
60
|
+
["astro", "astro", "web"],
|
|
61
|
+
["vue", "vue", "web"],
|
|
62
|
+
["svelte", "svelte", "web"],
|
|
63
|
+
["react", "react", "web"],
|
|
64
|
+
["vite", "vite", "web"],
|
|
65
|
+
["@nestjs/core", "nest", "backend"],
|
|
66
|
+
["express", "express", "backend"],
|
|
67
|
+
["fastify", "fastify", "backend"],
|
|
68
|
+
["hono", "hono", "backend"],
|
|
69
|
+
["koa", "koa", "backend"],
|
|
70
|
+
];
|
|
71
|
+
// Backend frameworks in other languages, found by name in the project's dependency file. A
|
|
72
|
+
// project whose frameworks aren't listed is still recognised by its language.
|
|
73
|
+
const PYTHON_FRAMEWORKS = ["fastapi", "django", "flask"];
|
|
74
|
+
const GO_FRAMEWORKS = [
|
|
75
|
+
["github.com/gin-gonic/gin", "gin"],
|
|
76
|
+
["github.com/labstack/echo", "echo"],
|
|
77
|
+
["github.com/gofiber/fiber", "fiber"],
|
|
78
|
+
["github.com/go-chi/chi", "chi"],
|
|
79
|
+
];
|
|
80
|
+
const RUST_FRAMEWORKS = [
|
|
81
|
+
["axum", "axum"],
|
|
82
|
+
["actix-web", "actix"],
|
|
83
|
+
["rocket", "rocket"],
|
|
84
|
+
["warp", "warp"],
|
|
85
|
+
];
|
|
86
|
+
const JVM_FRAMEWORKS = ["spring", "ktor", "quarkus", "micronaut"];
|
|
87
|
+
const RUBY_FRAMEWORKS = ["rails", "sinatra", "hanami"];
|
|
88
|
+
const PHP_FRAMEWORKS = [
|
|
89
|
+
["laravel", "laravel"],
|
|
90
|
+
["symfony/framework-bundle", "symfony"],
|
|
91
|
+
["slim/slim", "slim"],
|
|
92
|
+
];
|
|
93
|
+
// Infrastructure as code, recognised by its files rather than by where it lives.
|
|
94
|
+
const IAC_SIGNATURES = [
|
|
95
|
+
[(file) => file.endsWith(".tf") || file.endsWith(".tf.json"), "terraform"],
|
|
96
|
+
[(file) => file === "Pulumi.yaml" || file === "Pulumi.yml", "pulumi"],
|
|
97
|
+
[(file) => file === "cdk.json", "aws-cdk"],
|
|
98
|
+
[(file) => file === "Chart.yaml", "helm"],
|
|
99
|
+
[(file) => file === "kustomization.yaml" || file === "kustomization.yml", "kustomize"],
|
|
100
|
+
[(file) => file === "serverless.yml" || file === "serverless.yaml", "serverless"],
|
|
101
|
+
[(file) => file === "samconfig.toml", "aws-sam"],
|
|
102
|
+
[(file) => /\.cfn\.(ya?ml|json)$/.test(file), "cloudformation"],
|
|
103
|
+
[(file) => file.endsWith(".bicep"), "bicep"],
|
|
104
|
+
[(file) => file === "ansible.cfg" || file === "playbook.yml" || file === "site.yml", "ansible"],
|
|
105
|
+
];
|
|
106
|
+
const DOCKER_FILE = /^(Dockerfile|docker-compose.*\.ya?ml|compose\.ya?ml)$|\.Dockerfile$/;
|
|
107
|
+
// A platform's config file in a part's folder says where that part deploys. More specific
|
|
108
|
+
// platforms come first; a bare Dockerfile only says it ships as a container.
|
|
109
|
+
const DEPLOY_TARGETS = [
|
|
110
|
+
["vercel.json", "vercel"],
|
|
111
|
+
["netlify.toml", "netlify"],
|
|
112
|
+
["fly.toml", "fly"],
|
|
113
|
+
["render.yaml", "render"],
|
|
114
|
+
["railway.json", "railway"],
|
|
115
|
+
["railway.toml", "railway"],
|
|
116
|
+
["wrangler.toml", "cloudflare-workers"],
|
|
117
|
+
["wrangler.json", "cloudflare-workers"],
|
|
118
|
+
["wrangler.jsonc", "cloudflare-workers"],
|
|
119
|
+
["firebase.json", "firebase"],
|
|
120
|
+
["amplify.yml", "aws-amplify"],
|
|
121
|
+
["eas.json", "expo-eas"],
|
|
122
|
+
["serverless.yml", "serverless"],
|
|
123
|
+
["serverless.yaml", "serverless"],
|
|
124
|
+
["Procfile", "heroku"],
|
|
125
|
+
["app.yaml", "gcp-app-engine"],
|
|
126
|
+
["fastlane", "fastlane"],
|
|
127
|
+
["Dockerfile", "container"],
|
|
128
|
+
];
|
|
129
|
+
const PIPELINES = [
|
|
130
|
+
[".github/workflows", true],
|
|
131
|
+
[".gitlab-ci.yml", false],
|
|
132
|
+
["Jenkinsfile", false],
|
|
133
|
+
["bitbucket-pipelines.yml", false],
|
|
134
|
+
["azure-pipelines.yml", false],
|
|
135
|
+
[".circleci", true],
|
|
136
|
+
[".buildkite", true],
|
|
137
|
+
[".drone.yml", false],
|
|
138
|
+
[".travis.yml", false],
|
|
139
|
+
["cloudbuild.yaml", false],
|
|
140
|
+
["codemagic.yaml", false],
|
|
141
|
+
["bitrise.yml", false],
|
|
142
|
+
];
|
|
143
|
+
const isDir = (path) => existsSync(path) && statSync(path).isDirectory();
|
|
144
|
+
const readText = (path) => (existsSync(path) ? readFileSync(path, "utf8") : "");
|
|
145
|
+
const listDir = (dir) => (isDir(dir) ? readdirSync(dir) : []);
|
|
146
|
+
function readJson(path) {
|
|
147
|
+
try {
|
|
148
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
149
|
+
return typeof value === "object" && value !== null ? value : undefined;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function dependencyNames(pkg) {
|
|
156
|
+
const sections = [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies];
|
|
157
|
+
return sections.flatMap((section) => (typeof section === "object" && section !== null ? Object.keys(section) : []));
|
|
158
|
+
}
|
|
159
|
+
export function slugify(text) {
|
|
160
|
+
const slug = text
|
|
161
|
+
.toLowerCase()
|
|
162
|
+
.replace(/^@[^/]+\//, "")
|
|
163
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
164
|
+
.replace(/^-+|-+$/g, "");
|
|
165
|
+
return /^[a-z]/.test(slug) ? slug : slug === "" ? "app" : `app-${slug}`;
|
|
166
|
+
}
|
|
167
|
+
function hasTypescript(dir) {
|
|
168
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
169
|
+
return ((pkg !== undefined && dependencyNames(pkg).includes("typescript")) ||
|
|
170
|
+
existsSync(join(dir, "tsconfig.json")) ||
|
|
171
|
+
existsSync(join(dir, "tsconfig.base.json")));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* File and folder names in a directory and its subdirectories, down to a depth. Infrastructure
|
|
175
|
+
* is often nested, such as terraform/envs/production/main.tf.
|
|
176
|
+
*/
|
|
177
|
+
function namesWithin(dir, depth) {
|
|
178
|
+
return listDir(dir).flatMap((entry) => {
|
|
179
|
+
const nested = depth > 1 && !entry.startsWith(".") && entry !== "node_modules" && isDir(join(dir, entry));
|
|
180
|
+
return nested ? [entry, ...namesWithin(join(dir, entry), depth - 1)] : [entry];
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// In a monorepo, TypeScript is often installed once at the root rather than in each part.
|
|
184
|
+
function detectNodeTrack(dir, inheritsTypescript) {
|
|
185
|
+
const pkg = readJson(join(dir, "package.json"));
|
|
186
|
+
if (pkg === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
const deps = dependencyNames(pkg);
|
|
189
|
+
const stack = [];
|
|
190
|
+
if (inheritsTypescript || hasTypescript(dir))
|
|
191
|
+
stack.push("typescript");
|
|
192
|
+
let kind;
|
|
193
|
+
for (const [dependency, tag, frameworkKind] of NODE_FRAMEWORKS) {
|
|
194
|
+
if (!deps.includes(dependency))
|
|
195
|
+
continue;
|
|
196
|
+
stack.push(tag);
|
|
197
|
+
kind ??= frameworkKind;
|
|
198
|
+
}
|
|
199
|
+
kind ??= pkg.bin === undefined ? "library" : "cli";
|
|
200
|
+
if (!stack.includes("typescript"))
|
|
201
|
+
stack.unshift("javascript");
|
|
202
|
+
return { kind, stack };
|
|
203
|
+
}
|
|
204
|
+
/** A backend when it depends on one of its language's frameworks, otherwise a library. */
|
|
205
|
+
function byFramework(language, manifest, frameworks) {
|
|
206
|
+
const framework = frameworks.find(([needle]) => manifest.includes(needle))?.[1];
|
|
207
|
+
return framework === undefined
|
|
208
|
+
? { kind: "library", stack: [language] }
|
|
209
|
+
: { kind: "backend", stack: [language, framework] };
|
|
210
|
+
}
|
|
211
|
+
const named = (names) => names.map((name) => [name, name]);
|
|
212
|
+
/** An application or library part, recognised by its language's project files. */
|
|
213
|
+
function detectApp(dir, inheritsTypescript) {
|
|
214
|
+
const node = detectNodeTrack(dir, inheritsTypescript);
|
|
215
|
+
if (node !== undefined)
|
|
216
|
+
return node;
|
|
217
|
+
const python = `${readText(join(dir, "pyproject.toml"))}\n${readText(join(dir, "requirements.txt"))}`.toLowerCase();
|
|
218
|
+
if (python.trim() !== "")
|
|
219
|
+
return byFramework("python", python, named(PYTHON_FRAMEWORKS));
|
|
220
|
+
const pubspec = readText(join(dir, "pubspec.yaml"));
|
|
221
|
+
if (pubspec !== "") {
|
|
222
|
+
if (!pubspec.includes("flutter"))
|
|
223
|
+
return { kind: "library", stack: ["dart"] };
|
|
224
|
+
// A Flutter app has an entry point or platform folders; a Flutter package has neither.
|
|
225
|
+
const isApp = ["lib/main.dart", "android", "ios"].some((path) => existsSync(join(dir, path)));
|
|
226
|
+
return { kind: isApp ? "mobile" : "library", stack: ["dart", "flutter"] };
|
|
227
|
+
}
|
|
228
|
+
const gradle = readText(join(dir, "build.gradle.kts")) + readText(join(dir, "build.gradle"));
|
|
229
|
+
const maven = readText(join(dir, "pom.xml"));
|
|
230
|
+
if (gradle !== "" || maven !== "") {
|
|
231
|
+
const language = existsSync(join(dir, "build.gradle.kts")) ? "kotlin" : "java";
|
|
232
|
+
if (gradle.includes("com.android.application"))
|
|
233
|
+
return { kind: "mobile", stack: ["kotlin", "android"] };
|
|
234
|
+
const framework = JVM_FRAMEWORKS.find((name) => (gradle + maven).includes(name));
|
|
235
|
+
return { kind: "backend", stack: framework === undefined ? [language] : [language, framework] };
|
|
236
|
+
}
|
|
237
|
+
const csproj = listDir(dir).find((file) => file.endsWith(".csproj"));
|
|
238
|
+
if (csproj !== undefined) {
|
|
239
|
+
const project = readText(join(dir, csproj));
|
|
240
|
+
if (project.includes("<UseMaui>true"))
|
|
241
|
+
return { kind: "mobile", stack: ["csharp", "dotnet", "maui"] };
|
|
242
|
+
if (project.includes("Microsoft.NET.Sdk.BlazorWebAssembly")) {
|
|
243
|
+
return { kind: "web", stack: ["csharp", "dotnet", "blazor"] };
|
|
244
|
+
}
|
|
245
|
+
if (project.includes("Microsoft.NET.Sdk.Web"))
|
|
246
|
+
return { kind: "backend", stack: ["csharp", "dotnet", "aspnet"] };
|
|
247
|
+
return { kind: "library", stack: ["csharp", "dotnet"] };
|
|
248
|
+
}
|
|
249
|
+
if (listDir(dir).some((file) => file.endsWith(".xcodeproj")))
|
|
250
|
+
return { kind: "mobile", stack: ["swift", "ios"] };
|
|
251
|
+
const swiftPackage = readText(join(dir, "Package.swift"));
|
|
252
|
+
if (swiftPackage !== "")
|
|
253
|
+
return byFramework("swift", swiftPackage, [["vapor", "vapor"]]);
|
|
254
|
+
// A Go module is a backend even without a framework, since Go's own library serves HTTP.
|
|
255
|
+
const goMod = readText(join(dir, "go.mod"));
|
|
256
|
+
if (goMod !== "") {
|
|
257
|
+
const framework = GO_FRAMEWORKS.find(([module]) => goMod.includes(module))?.[1];
|
|
258
|
+
return { kind: "backend", stack: framework === undefined ? ["go"] : ["go", framework] };
|
|
259
|
+
}
|
|
260
|
+
const cargo = readText(join(dir, "Cargo.toml"));
|
|
261
|
+
if (cargo !== "") {
|
|
262
|
+
const framework = RUST_FRAMEWORKS.find(([crate]) => new RegExp(`^${crate}\\s*=`, "m").test(cargo))?.[1];
|
|
263
|
+
return framework === undefined
|
|
264
|
+
? { kind: "other", stack: ["rust"] }
|
|
265
|
+
: { kind: "backend", stack: ["rust", framework] };
|
|
266
|
+
}
|
|
267
|
+
const gemfile = readText(join(dir, "Gemfile"));
|
|
268
|
+
if (gemfile !== "")
|
|
269
|
+
return byFramework("ruby", gemfile, named(RUBY_FRAMEWORKS));
|
|
270
|
+
const composer = readText(join(dir, "composer.json"));
|
|
271
|
+
if (composer !== "")
|
|
272
|
+
return byFramework("php", composer, PHP_FRAMEWORKS);
|
|
273
|
+
const mix = readText(join(dir, "mix.exs"));
|
|
274
|
+
if (mix !== "")
|
|
275
|
+
return byFramework("elixir", mix, [[":phoenix", "phoenix"]]);
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Infrastructure as code. Docker files count only inside an infrastructure folder, so a
|
|
280
|
+
* docker-compose file used for local development is not mistaken for infrastructure.
|
|
281
|
+
*/
|
|
282
|
+
function detectInfrastructure(dir, options) {
|
|
283
|
+
const files = options.deep ? namesWithin(dir, 5) : listDir(dir);
|
|
284
|
+
const stack = IAC_SIGNATURES.filter(([matches]) => files.some(matches)).map(([, tag]) => tag);
|
|
285
|
+
if (["k8s", "kubernetes"].includes(basename(dir)) && files.some((file) => /\.ya?ml$/.test(file))) {
|
|
286
|
+
stack.push("kubernetes");
|
|
287
|
+
}
|
|
288
|
+
if (options.includeDocker && files.some((file) => DOCKER_FILE.test(file)))
|
|
289
|
+
stack.push("docker");
|
|
290
|
+
return stack.length === 0 ? undefined : { kind: "infrastructure", stack };
|
|
291
|
+
}
|
|
292
|
+
export function detectDeploy(dir) {
|
|
293
|
+
for (const [file, target] of DEPLOY_TARGETS) {
|
|
294
|
+
if (!existsSync(join(dir, file)))
|
|
295
|
+
continue;
|
|
296
|
+
if (file === "app.yaml" && !readText(join(dir, file)).includes("runtime:"))
|
|
297
|
+
continue;
|
|
298
|
+
return target;
|
|
299
|
+
}
|
|
300
|
+
return undefined;
|
|
301
|
+
}
|
|
302
|
+
function withDeploy(track, dir) {
|
|
303
|
+
if (track.kind === "infrastructure" || track.kind === "library")
|
|
304
|
+
return track;
|
|
305
|
+
const deploy = detectDeploy(dir);
|
|
306
|
+
return deploy === undefined ? track : { ...track, deploy };
|
|
307
|
+
}
|
|
308
|
+
function isWorkspaceRoot(root) {
|
|
309
|
+
const markers = ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json", "go.work"];
|
|
310
|
+
if (markers.some((file) => existsSync(join(root, file))))
|
|
311
|
+
return true;
|
|
312
|
+
// A Cargo workspace root with no package of its own only groups the crates inside it.
|
|
313
|
+
const cargo = readText(join(root, "Cargo.toml"));
|
|
314
|
+
if (/^\[workspace\]/m.test(cargo) && !/^\[package\]/m.test(cargo))
|
|
315
|
+
return true;
|
|
316
|
+
return readJson(join(root, "package.json"))?.workspaces !== undefined;
|
|
317
|
+
}
|
|
318
|
+
const tomlValue = (text, key) => new RegExp(`^\\s*${key}\\s*=\\s*"([^"]+)"`, "m").exec(text)?.[1];
|
|
319
|
+
/** The project's name from its own project file, in whichever language it's written. */
|
|
320
|
+
export function detectName(root) {
|
|
321
|
+
const text = (file) => readText(join(root, file));
|
|
322
|
+
const pkgName = readJson(join(root, "package.json"))?.name;
|
|
323
|
+
const composerName = readJson(join(root, "composer.json"))?.name;
|
|
324
|
+
const goModule = /^module\s+(\S+)/m.exec(text("go.mod"))?.[1]?.replace(/\/v\d+$/, "");
|
|
325
|
+
const candidates = [
|
|
326
|
+
typeof pkgName === "string" ? pkgName.replace(/^@[^/]+\//, "") : undefined,
|
|
327
|
+
tomlValue(text("pyproject.toml"), "name"),
|
|
328
|
+
tomlValue(text("Cargo.toml"), "name"),
|
|
329
|
+
goModule?.split("/").pop(),
|
|
330
|
+
typeof composerName === "string" ? composerName.split("/").pop() : undefined,
|
|
331
|
+
/^name:\s*["']?([\w-]+)/m.exec(text("pubspec.yaml"))?.[1],
|
|
332
|
+
/rootProject\.name\s*=\s*["']([^"']+)["']/.exec(text("settings.gradle.kts") + text("settings.gradle"))?.[1],
|
|
333
|
+
];
|
|
334
|
+
return candidates.find((name) => name !== undefined && name !== "") ?? basename(root);
|
|
335
|
+
}
|
|
336
|
+
function detectDescription(root) {
|
|
337
|
+
const description = readJson(join(root, "package.json"))?.description;
|
|
338
|
+
if (typeof description === "string" && description !== "")
|
|
339
|
+
return description;
|
|
340
|
+
return (tomlValue(readText(join(root, "pyproject.toml")), "description") ??
|
|
341
|
+
tomlValue(readText(join(root, "Cargo.toml")), "description"));
|
|
342
|
+
}
|
|
343
|
+
export function detectTracks(root, projectName) {
|
|
344
|
+
const found = [];
|
|
345
|
+
if (!isWorkspaceRoot(root)) {
|
|
346
|
+
// The root is one app, or a repository that is itself an infrastructure project.
|
|
347
|
+
const app = detectApp(root, false);
|
|
348
|
+
const rootTrack = app === undefined ? detectInfrastructure(root, { deep: false, includeDocker: false }) : withDeploy(app, root);
|
|
349
|
+
if (rootTrack !== undefined)
|
|
350
|
+
found.push({ track: rootTrack, name: projectName });
|
|
351
|
+
}
|
|
352
|
+
const rootTypescript = hasTypescript(root);
|
|
353
|
+
for (const workspace of WORKSPACE_DIRS) {
|
|
354
|
+
for (const child of listDir(join(root, workspace)).sort()) {
|
|
355
|
+
const dir = join(root, workspace, child);
|
|
356
|
+
if (child.startsWith(".") || !isDir(dir))
|
|
357
|
+
continue;
|
|
358
|
+
const app = detectApp(dir, rootTypescript);
|
|
359
|
+
const track = app === undefined ? detectInfrastructure(dir, { deep: false, includeDocker: false }) : withDeploy(app, dir);
|
|
360
|
+
if (track !== undefined)
|
|
361
|
+
found.push({ path: `${workspace}/${child}`, track, name: child });
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
for (const infra of INFRA_DIRS) {
|
|
365
|
+
const dir = join(root, infra);
|
|
366
|
+
if (!isDir(dir))
|
|
367
|
+
continue;
|
|
368
|
+
const track = detectInfrastructure(dir, { deep: true, includeDocker: true }) ?? detectApp(dir, rootTypescript);
|
|
369
|
+
if (track !== undefined)
|
|
370
|
+
found.push({ path: infra, track, name: infra });
|
|
371
|
+
}
|
|
372
|
+
const used = new Set();
|
|
373
|
+
return found.map(({ path, track, name }) => {
|
|
374
|
+
let id = slugify(name);
|
|
375
|
+
for (let n = 2; used.has(id); n++)
|
|
376
|
+
id = `${slugify(name)}-${String(n)}`;
|
|
377
|
+
used.add(id);
|
|
378
|
+
return path === undefined ? { id, ...track } : { id, path, ...track };
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
export function detectDelivery(root) {
|
|
382
|
+
for (const [path, directory] of PIPELINES) {
|
|
383
|
+
const full = join(root, path);
|
|
384
|
+
const present = directory ? listDir(full).some((file) => /\.ya?ml$/.test(file)) : existsSync(full);
|
|
385
|
+
if (present)
|
|
386
|
+
return { ci: "existing", pipeline: directory ? `${path}/` : path };
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
export function detectTools(root) {
|
|
391
|
+
const tools = [];
|
|
392
|
+
if (existsSync(join(root, "CLAUDE.md")) || isDir(join(root, ".claude")))
|
|
393
|
+
tools.push("claude-code");
|
|
394
|
+
if (isDir(join(root, ".codex")))
|
|
395
|
+
tools.push("codex");
|
|
396
|
+
if (isDir(join(root, ".cursor")) || existsSync(join(root, ".cursorrules")))
|
|
397
|
+
tools.push("cursor");
|
|
398
|
+
if (existsSync(join(root, ".github", "copilot-instructions.md")))
|
|
399
|
+
tools.push("copilot");
|
|
400
|
+
if (existsSync(join(root, "GEMINI.md")) || isDir(join(root, ".gemini")))
|
|
401
|
+
tools.push("gemini-cli");
|
|
402
|
+
return tools;
|
|
403
|
+
}
|
|
404
|
+
/** The host name of a git remote, from an https, ssh:// or scp-style (git@host:path) URL. */
|
|
405
|
+
export function remoteHostname(url) {
|
|
406
|
+
const scp = /^[^@/\s]+@([^:/\s]+):/.exec(url);
|
|
407
|
+
if (scp?.[1] !== undefined)
|
|
408
|
+
return scp[1].toLowerCase();
|
|
409
|
+
try {
|
|
410
|
+
return new URL(url).hostname.toLowerCase() || undefined;
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Matched on the parsed host name against each service's real domain, never on the whole URL,
|
|
417
|
+
// so a URL that merely contains "github.com" is not mistaken for GitHub. A self-hosted server
|
|
418
|
+
// cannot be recognised reliably from its name, so it is recorded as "other" for the user to set.
|
|
419
|
+
export function classifyHost(hostname) {
|
|
420
|
+
if (hostname === undefined)
|
|
421
|
+
return "other";
|
|
422
|
+
const is = (domain) => hostname === domain || hostname.endsWith(`.${domain}`);
|
|
423
|
+
if (is("github.com"))
|
|
424
|
+
return "github";
|
|
425
|
+
if (is("gitlab.com"))
|
|
426
|
+
return "gitlab";
|
|
427
|
+
if (is("bitbucket.org"))
|
|
428
|
+
return "bitbucket";
|
|
429
|
+
if (is("dev.azure.com") || is("visualstudio.com"))
|
|
430
|
+
return "azure-devops";
|
|
431
|
+
return "other";
|
|
432
|
+
}
|
|
433
|
+
export function detectRepo(root) {
|
|
434
|
+
let url;
|
|
435
|
+
try {
|
|
436
|
+
url = execFileSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8", stdio: "pipe" }).trim();
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return { remote: null };
|
|
440
|
+
}
|
|
441
|
+
return { host: classifyHost(remoteHostname(url)), remote: "origin" };
|
|
442
|
+
}
|
|
443
|
+
export function detect(root) {
|
|
444
|
+
const name = detectName(root);
|
|
445
|
+
const tracks = detectTracks(root, name);
|
|
446
|
+
const description = detectDescription(root);
|
|
447
|
+
const delivery = detectDelivery(root);
|
|
448
|
+
const existing = tracks.length > 0 || PROJECT_MARKERS.some((marker) => existsSync(join(root, marker)));
|
|
449
|
+
return {
|
|
450
|
+
name,
|
|
451
|
+
...(description === undefined ? {} : { description }),
|
|
452
|
+
origin: existing ? "existing" : "new",
|
|
453
|
+
tools: detectTools(root),
|
|
454
|
+
repo: detectRepo(root),
|
|
455
|
+
...(delivery === undefined ? {} : { delivery }),
|
|
456
|
+
tracks,
|
|
457
|
+
hasConfig: existsSync(join(root, CONFIG_FILE)),
|
|
458
|
+
};
|
|
459
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type PeerAiConfig } from "peer-ai-workflow";
|
|
2
|
+
import { type Check } from "./checks.ts";
|
|
3
|
+
import type { Output } from "./init.ts";
|
|
4
|
+
export interface Diagnosis {
|
|
5
|
+
name: string;
|
|
6
|
+
ok: boolean;
|
|
7
|
+
checks: Check[];
|
|
8
|
+
}
|
|
9
|
+
export interface DiagnoseOptions {
|
|
10
|
+
/** Check the rendered skills too. CI never has them, since they stay out of git, unless the project commits them. */
|
|
11
|
+
skills?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function checkConfig(root: string): {
|
|
14
|
+
check: Check;
|
|
15
|
+
config?: PeerAiConfig;
|
|
16
|
+
};
|
|
17
|
+
/** Each track's folder exists, and every part found in the repository belongs to a track. */
|
|
18
|
+
export declare function checkTracks(root: string, config: PeerAiConfig): Check[];
|
|
19
|
+
/**
|
|
20
|
+
* Every rule the project sets aside or changes, listed so nothing is switched off silently (RFC
|
|
21
|
+
* 0003). An id with a core or stack profile prefix must be one of Peer AI's rules, and a changed
|
|
22
|
+
* rule must have a value to change (RFC 0006); other prefixes belong to project add-ons, which
|
|
23
|
+
* are listed as they are.
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkStandards(config: PeerAiConfig, today: string): Check[];
|
|
26
|
+
/** Every work item is valid, named after its id, and on a track the config has. */
|
|
27
|
+
export declare function checkWorkItems(root: string, config: PeerAiConfig | undefined): Check[];
|
|
28
|
+
export declare function diagnose(root: string, nodeVersion?: string, today?: Date, options?: DiagnoseOptions): Diagnosis;
|
|
29
|
+
export declare function formatDiagnosis(diagnosis: Diagnosis): string[];
|
|
30
|
+
export interface DoctorOptions {
|
|
31
|
+
cwd: string;
|
|
32
|
+
json: boolean;
|
|
33
|
+
nodeVersion?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Exit code 0 when nothing failed, even with warnings; 1 when something failed. */
|
|
36
|
+
export declare function runDoctor(options: DoctorOptions, out: Output): number;
|