staysfixed 0.3.0 → 0.4.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/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
package/src/v2/detect.js
ADDED
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What IS this project?
|
|
3
|
+
*
|
|
4
|
+
* Everything else in version 2 answers "what changed". This file answers the question that
|
|
5
|
+
* comes before it, and it answers it WITHOUT ASKING ANYBODY. An agent told to install this
|
|
6
|
+
* tool into a stranger's repository should never have to type "is this a website or a
|
|
7
|
+
* desktop app?" into a chat window. The repository already says. It says it in its
|
|
8
|
+
* package.json, in its lockfile, in the shape of its folders, in the config files its
|
|
9
|
+
* framework leaves lying around, in the built artifacts sitting in `out/`, in an `.xcodeproj`,
|
|
10
|
+
* in a `gradlew`, in a Dockerfile, in the name of its test runner. All of that is free to
|
|
11
|
+
* read and none of it needs a person.
|
|
12
|
+
*
|
|
13
|
+
* THE ANSWER IS USUALLY MORE THAN ONE THING, and that is the part every tool like this gets
|
|
14
|
+
* wrong. A repository is not "a Node project". Terminal Deck is one repository that produces
|
|
15
|
+
* a desktop app, an iPhone app, an Android app, a small web client and a relay server — five
|
|
16
|
+
* products, five toolchains, one shared `src/`. A tool that picks the single best-matching
|
|
17
|
+
* label and moves on will check the desktop app, report a clean run, and say nothing at all
|
|
18
|
+
* about the phone. So this file returns a LIST of products, each with its own evidence, its
|
|
19
|
+
* own confidence, its own adapter and its own reason. Several at once is the normal case.
|
|
20
|
+
*
|
|
21
|
+
* WHAT IT NEVER DOES. It never runs anything — no `npm install`, no build, no dev server, no
|
|
22
|
+
* `node -e`. It only reads. That matters twice over: reading is safe to do inside somebody
|
|
23
|
+
* else's repository while they have it open in an editor, and it is safe to do inside a
|
|
24
|
+
* repository nobody has audited. It also never writes. `init.js` writes; this file looks.
|
|
25
|
+
*
|
|
26
|
+
* HOW SURE IT IS, SAID OUT LOUD. Every product carries `confidence` and, more usefully,
|
|
27
|
+
* `evidence` — the actual files that made it say so. A wrong guess with its evidence attached
|
|
28
|
+
* is a thirty-second fix for an agent. A wrong guess with no evidence is an argument.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import fsp from 'node:fs/promises';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
|
|
35
|
+
/** @typedef {import('./types.js').Surface} Surface */
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// The kinds of thing a repository can produce
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every kind of product this tool has a name for, what it means in plain English, which
|
|
43
|
+
* observation surface it belongs to, and which adapter would drive it.
|
|
44
|
+
*
|
|
45
|
+
* `adapter` names the adapter that WOULD drive this kind of product. Whether that adapter is
|
|
46
|
+
* actually in this copy of the tool is a separate question, answered at run time by looking
|
|
47
|
+
* for the file — see {@link adaptersHere}. Six adapters are being written in parallel, and a
|
|
48
|
+
* table that hard-coded which ones exist would start lying the day one of them landed: it
|
|
49
|
+
* would tell somebody their Android app cannot be checked while the Android adapter sat right
|
|
50
|
+
* there. `null` means no adapter is even planned, which is a different and permanent answer.
|
|
51
|
+
*
|
|
52
|
+
* @type {Readonly<Record<string, {name: string, surface: Surface, adapter: string|null, what: string}>>}
|
|
53
|
+
*/
|
|
54
|
+
export const PRODUCT_KINDS = Object.freeze({
|
|
55
|
+
cli: { name: 'a command-line tool', surface: 'cli', adapter: 'process', what: 'Something you type at a terminal. Watched by running it and reading what it printed, what it exited with and what it wrote.' },
|
|
56
|
+
library: { name: 'a library other code imports', surface: 'library', adapter: 'process', what: 'Code other projects import. Watched by importing it and comparing what it exports and what those exports do.' },
|
|
57
|
+
server: { name: 'a server or an API', surface: 'server', adapter: 'http', what: 'Something that answers requests. Watched by booting it on a spare port and asking it for every route read out of its own source.' },
|
|
58
|
+
web: { name: 'a website or web app', surface: 'web', adapter: 'web', what: 'Something people open in a browser. Watched by opening it in a throwaway browser and reading what the screen says each control is and does.' },
|
|
59
|
+
electron: { name: 'a desktop app built with Electron', surface: 'electron', adapter: 'electron', what: 'A desktop app. Watched by opening the built app on its own, reading its window, its menus and every private channel it registers.' },
|
|
60
|
+
ios: { name: 'an iPhone or iPad app', surface: 'ios', adapter: 'ios', what: 'An Apple app. Driven on the simulator; a real device in your hand can never be compared side by side.' },
|
|
61
|
+
android: { name: 'an Android app', surface: 'android', adapter: 'android', what: 'An Android app. Driven on an emulator against the stored record.' },
|
|
62
|
+
desktopNative: { name: 'a native desktop app', surface: 'windows', adapter: 'windows', what: 'A desktop app that is not Electron — Swift, WinUI, Tauri, Qt. Only readable from the operating system it runs on.' },
|
|
63
|
+
container: { name: 'a containerised service', surface: 'server', adapter: 'http', what: 'A service that ships as a container. Watched the same way as any server, once there is a command that starts it.' },
|
|
64
|
+
other: { name: 'a product in a language this tool cannot drive yet', surface: 'cli', adapter: null, what: 'Recognised, named, and honestly not drivable here. It is listed so a clean run is never mistaken for full coverage.' },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Which adapters are actually in this copy of the tool, found by looking for the file.
|
|
69
|
+
*
|
|
70
|
+
* Cheap, and deliberately not an import: importing every adapter to find out whether it is
|
|
71
|
+
* there costs a second and can throw. The folder is the register, and it updates itself the
|
|
72
|
+
* moment somebody adds one.
|
|
73
|
+
*
|
|
74
|
+
* @returns {Set<string>}
|
|
75
|
+
*/
|
|
76
|
+
export function adaptersHere() {
|
|
77
|
+
/** @type {Set<string>} */
|
|
78
|
+
const here = new Set();
|
|
79
|
+
try {
|
|
80
|
+
const dir = path.join(path.dirname(new URL(import.meta.url).pathname), 'adapters');
|
|
81
|
+
for (const name of fs.readdirSync(dir)) {
|
|
82
|
+
if (!name.endsWith('.js')) continue;
|
|
83
|
+
const id = name.slice(0, -3);
|
|
84
|
+
// Two files in there are not adapters: the interface every adapter is written against,
|
|
85
|
+
// and the helpers two of them share.
|
|
86
|
+
if (id === 'contract' || id === 'isolate' || id.endsWith('-driver')) continue;
|
|
87
|
+
here.add(id);
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// A copy of the tool whose own folder cannot be read tells us nothing, and claiming no
|
|
91
|
+
// adapter exists would be a worse answer than claiming the usual ones do.
|
|
92
|
+
return new Set(['process', 'source', 'http', 'web', 'electron']);
|
|
93
|
+
}
|
|
94
|
+
return here;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Folders never worth walking into. Walking `node_modules` is how a detector takes a minute. */
|
|
98
|
+
const SKIP_DIRS = new Set([
|
|
99
|
+
'node_modules', '.git', '.hg', '.svn', 'dist', 'out', 'build', 'release', 'coverage',
|
|
100
|
+
'.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.parcel-cache', 'vendor', 'Pods',
|
|
101
|
+
'.venv', 'venv', '__pycache__', 'target', '.gradle', '.idea', '.vscode', 'DerivedData',
|
|
102
|
+
'.staysfixed', 'tmp', 'temp', '.yarn', '.pnpm-store', 'Carthage',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/** Folders that are worth looking INSIDE for a second product, even without a package.json. */
|
|
106
|
+
const PLATFORM_FOLDERS = [
|
|
107
|
+
'ios', 'android', 'apple', 'mobile', 'app', 'apps', 'desktop', 'electron', 'main',
|
|
108
|
+
'web', 'site', 'www', 'client', 'frontend', 'pwa', 'server', 'api', 'backend',
|
|
109
|
+
'relay', 'service', 'services', 'packages', 'native', 'src-tauri', 'cmd',
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
/** How many files the artifact and test sweeps will look at before giving up and saying so. */
|
|
113
|
+
const MOST_FILES = 20_000;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Folders that are never a product of their own: either the contract channel already read
|
|
117
|
+
* them as part of the root product, or they hold work about the project rather than the
|
|
118
|
+
* project. Flagging one of these as "code nobody is checking" would be noise, and noise is
|
|
119
|
+
* how an honest warning gets ignored.
|
|
120
|
+
*/
|
|
121
|
+
const ALREADY_COVERED = new Set([
|
|
122
|
+
'src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages',
|
|
123
|
+
'scripts', 'tools', 'test', 'tests', '__tests__', 'spec', 'e2e', 'docs', 'doc',
|
|
124
|
+
'examples', 'example', 'fixtures', 'types', 'typings', 'config', 'public', 'static',
|
|
125
|
+
'assets', 'styles', 'migrations', 'benchmarks',
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// The shapes this file hands back
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One thing that was actually found on disk, and what it means.
|
|
134
|
+
*
|
|
135
|
+
* Kept as data rather than folded into a sentence because an agent correcting a wrong guess
|
|
136
|
+
* wants the file, and a person reading the summary wants the sentence, and building the
|
|
137
|
+
* sentence from the file means the two can never disagree.
|
|
138
|
+
*
|
|
139
|
+
* @typedef {object} Clue
|
|
140
|
+
* @property {string} where Path relative to the project root, or a key inside package.json.
|
|
141
|
+
* @property {string} means Plain English: what finding this tells us.
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One product this repository makes.
|
|
146
|
+
*
|
|
147
|
+
* @typedef {object} Product
|
|
148
|
+
* @property {string} kind A key of PRODUCT_KINDS.
|
|
149
|
+
* @property {string} name Plain English, specific to this one: 'the desktop app'.
|
|
150
|
+
* @property {Surface} surface
|
|
151
|
+
* @property {string|null} adapter Which adapter would drive it, or null when none can yet.
|
|
152
|
+
* @property {number} confidence 0..1. How sure, and it is allowed to be unsure.
|
|
153
|
+
* @property {string} why One plain sentence naming what made us say so.
|
|
154
|
+
* @property {string} where Folder this product lives in, relative to the root. '.' for the root.
|
|
155
|
+
* @property {Clue[]} evidence Every file that contributed. The whole audit trail.
|
|
156
|
+
* @property {{found: boolean, where: string|null, how: string}} built
|
|
157
|
+
* Whether a built artifact is sitting there ready to be opened.
|
|
158
|
+
* @property {string[]} blockers Plain sentences: what stands between this and being checked.
|
|
159
|
+
* @property {Record<string, any>} [suggest] The config slice init would write for it.
|
|
160
|
+
*/
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Everything read off a repository without running any of it.
|
|
164
|
+
*
|
|
165
|
+
* @typedef {object} ProjectShape
|
|
166
|
+
* @property {string} root
|
|
167
|
+
* @property {string} name
|
|
168
|
+
* @property {string|null} version
|
|
169
|
+
* @property {boolean} isGitRepo
|
|
170
|
+
* @property {Product[]} products Ranked, most certain first. Several is normal.
|
|
171
|
+
* @property {string} summary One plain sentence naming what this repository makes.
|
|
172
|
+
* @property {{name: string, lockfile: string|null, why: string}} packageManager
|
|
173
|
+
* @property {string[]} workspaces Workspace globs, when it is a monorepo.
|
|
174
|
+
* @property {{name: string, root: string}[]} members Sub-packages actually found.
|
|
175
|
+
* @property {string[]} languages Languages seen, most-used first.
|
|
176
|
+
* @property {{runner: string|null, command: string|null, files: number, folders: string[], why: string}} tests
|
|
177
|
+
* @property {{dev: string|null, start: string|null, build: string|null, test: string|null, typecheck: string|null, package: string|null}} scripts
|
|
178
|
+
* @property {{ipc: number, route: number, export: number, command: number, env: number, unnamed: number, filesRead: number, read: boolean, why: string}} doors
|
|
179
|
+
* @property {{name: string, method: string, file: string}[]} routes Every route, by name. Capped.
|
|
180
|
+
* @property {{url: string, file: string, needs: string[]}[]} pages
|
|
181
|
+
* @property {{dockerfile: string|null, compose: string|null}} containers
|
|
182
|
+
* @property {Clue[]} evidence Everything found, including clues no product claimed.
|
|
183
|
+
* @property {string[]} unsure Things it could not work out, in plain English.
|
|
184
|
+
* @property {number} durationMs
|
|
185
|
+
*/
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// The front door
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Work out what a project is, by reading it.
|
|
193
|
+
*
|
|
194
|
+
* @param {object} [options]
|
|
195
|
+
* @param {string} [options.root] Folder to look at. Defaults to the current one.
|
|
196
|
+
* @param {boolean} [options.readCode] Read the source for routes, IPC channels and exports.
|
|
197
|
+
* On by default: it is the only way to see a door
|
|
198
|
+
* nobody linked to. Turn it off for a fast answer on a
|
|
199
|
+
* very large repository.
|
|
200
|
+
* @param {boolean} [options.deep] Look inside sub-folders for more products. On by default,
|
|
201
|
+
* and it is what finds the phone app in a desktop repo.
|
|
202
|
+
* @returns {Promise<ProjectShape>}
|
|
203
|
+
*/
|
|
204
|
+
export async function detectProject(options = {}) {
|
|
205
|
+
const started = Date.now();
|
|
206
|
+
const root = path.resolve(options.root ?? process.cwd());
|
|
207
|
+
const readCode = options.readCode !== false;
|
|
208
|
+
const deep = options.deep !== false;
|
|
209
|
+
|
|
210
|
+
/** @type {Clue[]} */
|
|
211
|
+
const evidence = [];
|
|
212
|
+
/** @type {string[]} */
|
|
213
|
+
const unsure = [];
|
|
214
|
+
|
|
215
|
+
const pkg = await readJson(path.join(root, 'package.json'));
|
|
216
|
+
if (pkg) evidence.push({ where: 'package.json', means: `This is an npm package called ${String(pkg.name ?? 'something with no name')}.` });
|
|
217
|
+
|
|
218
|
+
const listing = await listOnce(root);
|
|
219
|
+
const manager = packageManagerOf(listing, pkg);
|
|
220
|
+
if (manager.lockfile) evidence.push({ where: manager.lockfile, means: `Dependencies are installed with ${manager.name}.` });
|
|
221
|
+
|
|
222
|
+
const workspaces = workspaceGlobsOf(pkg, listing, root);
|
|
223
|
+
const members = deep ? await findMembers(root, workspaces, listing) : [];
|
|
224
|
+
for (const member of members) evidence.push({ where: member.root + '/package.json', means: `A second package inside this repository, called ${member.name}.` });
|
|
225
|
+
|
|
226
|
+
// The source read, once, for two answers — how many doors there are, and what the routes
|
|
227
|
+
// are called. Reading Terminal Deck's 1,416 files twice because two functions each wanted
|
|
228
|
+
// their own copy cost a second and a half of the two this whole detection takes.
|
|
229
|
+
const reading = readCode ? await readTheSource(root) : { doors: notRead(), routes: [] };
|
|
230
|
+
const doors = reading.doors;
|
|
231
|
+
const routes = reading.routes;
|
|
232
|
+
const pages = readCode ? await readThePages(root) : [];
|
|
233
|
+
if (doors.read && doors.route + doors.ipc > 0) {
|
|
234
|
+
evidence.push({ where: 'the source', means: `${doors.route} route${doors.route === 1 ? '' : 's'} and ${doors.ipc} private channel${doors.ipc === 1 ? '' : 's'} are written in the code.` });
|
|
235
|
+
}
|
|
236
|
+
if (pages.length > 0) evidence.push({ where: 'the page folders', means: `${pages.length} page address${pages.length === 1 ? '' : 'es'} come from the names of folders, the way Next.js and its cousins do it.` });
|
|
237
|
+
|
|
238
|
+
const tests = await findTests(root, pkg, listing);
|
|
239
|
+
if (tests.runner) evidence.push({ where: tests.folders[0] ?? 'package.json', means: `Its own tests are written with ${tests.runner}, and there are ${tests.files} of them. Those are journeys this tool can walk instead of inventing its own.` });
|
|
240
|
+
|
|
241
|
+
const containers = {
|
|
242
|
+
dockerfile: listing.files.find((f) => f === 'Dockerfile' || f.startsWith('Dockerfile.')) ?? null,
|
|
243
|
+
compose: listing.files.find((f) => /^(docker-)?compose\.ya?ml$/.test(f)) ?? null,
|
|
244
|
+
};
|
|
245
|
+
if (containers.dockerfile) evidence.push({ where: containers.dockerfile, means: 'It ships as a container, so there is a known way to start it and to put its data back.' });
|
|
246
|
+
|
|
247
|
+
// Every place a product might live: the root, each workspace member, and each platform
|
|
248
|
+
// folder that is plainly a product of its own even without a package.json — `ios/` holding
|
|
249
|
+
// an Xcode project is the case that matters, and it has no package.json anywhere near it.
|
|
250
|
+
/** @type {{root: string, pkg: any}[]} */
|
|
251
|
+
const places = [{ root: '.', pkg }];
|
|
252
|
+
for (const member of members) places.push({ root: member.root, pkg: member.pkg });
|
|
253
|
+
if (deep) {
|
|
254
|
+
for (const folder of listing.dirs) {
|
|
255
|
+
if (SKIP_DIRS.has(folder)) continue;
|
|
256
|
+
if (!PLATFORM_FOLDERS.includes(folder)) continue;
|
|
257
|
+
if (places.some((p) => p.root === folder)) continue;
|
|
258
|
+
places.push({ root: folder, pkg: await readJson(path.join(root, folder, 'package.json')) });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const available = adaptersHere();
|
|
263
|
+
/** @type {Product[]} */
|
|
264
|
+
const products = [];
|
|
265
|
+
for (const place of places) {
|
|
266
|
+
const where = path.join(root, place.root);
|
|
267
|
+
const local = place.root === '.' ? listing : await listOnce(where);
|
|
268
|
+
products.push(...(await productsIn({
|
|
269
|
+
root, where: place.root, listing: local, pkg: place.pkg,
|
|
270
|
+
// Doors and pages were read from the root, so they only describe the root. A
|
|
271
|
+
// sub-package gets credited with them only when it IS the root.
|
|
272
|
+
doors: place.root === '.' ? doors : notRead(),
|
|
273
|
+
pages: place.root === '.' ? pages : [],
|
|
274
|
+
containers: place.root === '.' ? containers : { dockerfile: null, compose: null },
|
|
275
|
+
scripts: place.pkg?.scripts ?? {},
|
|
276
|
+
available,
|
|
277
|
+
})));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const merged = mergeProducts(products);
|
|
281
|
+
if (merged.length === 0) {
|
|
282
|
+
unsure.push('Nothing here looks like a product this tool knows how to watch. If it is one, say so in the settings: put a "kind" under the adapter that fits, and everything else follows from that.');
|
|
283
|
+
}
|
|
284
|
+
const guessy = merged.filter((p) => p.confidence < 0.5).map((p) => p.name);
|
|
285
|
+
if (guessy.length > 0) unsure.push(`These were guessed from weak evidence and are worth a second look: ${guessy.join(', ')}.`);
|
|
286
|
+
if (!doors.read) unsure.push('The source was not read, so no routes, exported names or private channels were counted.');
|
|
287
|
+
|
|
288
|
+
// A folder full of code that no product claimed is the most dangerous thing this file can
|
|
289
|
+
// find, because everything else about the run will look complete. Terminal Deck's `relay/`
|
|
290
|
+
// is exactly it: real source, no package.json, no framework, nothing to match on. Naming
|
|
291
|
+
// the folder is honest and cheap; guessing what it is would be neither.
|
|
292
|
+
if (deep) {
|
|
293
|
+
const claimed = new Set(merged.map((p) => p.where));
|
|
294
|
+
for (const folder of listing.dirs) {
|
|
295
|
+
if (SKIP_DIRS.has(folder) || folder.startsWith('.') || claimed.has(folder)) continue;
|
|
296
|
+
if (members.some((m) => m.root === folder)) continue;
|
|
297
|
+
// The root product's own source is not an unclaimed folder. These are the folders the
|
|
298
|
+
// contract channel already read, plus the ones that are never a product on their own.
|
|
299
|
+
if (ALREADY_COVERED.has(folder)) continue;
|
|
300
|
+
// And neither is a folder the root product simply imports from. `components/` in a
|
|
301
|
+
// Next.js site holds a hundred files and is not a second product; flagging it would be
|
|
302
|
+
// noise, and noise is how a real warning like `relay/` gets skimmed past. So only a
|
|
303
|
+
// folder NAMED like a product, or one carrying its own container, is worth saying.
|
|
304
|
+
const own = PLATFORM_FOLDERS.includes(folder) || fs.existsSync(path.join(root, folder, 'Dockerfile'));
|
|
305
|
+
if (!own) continue;
|
|
306
|
+
const codeFiles = await countMatching(path.join(root, folder), /\.[cm]?[jt]sx?$/);
|
|
307
|
+
// Three is the line. Terminal Deck's relay is three files and it is a real running
|
|
308
|
+
// service; one or two files is a helper somebody left lying about.
|
|
309
|
+
if (codeFiles >= 3) {
|
|
310
|
+
unsure.push(`${folder}/ holds ${codeFiles} source files and nothing here could work out what it produces, so nothing in it is being checked. If it is a product of its own, say what starts it in the settings.`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const product of merged) evidence.push(...product.evidence);
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
root,
|
|
319
|
+
name: String(pkg?.name ?? path.basename(root)),
|
|
320
|
+
version: pkg?.version ? String(pkg.version) : null,
|
|
321
|
+
isGitRepo: fs.existsSync(path.join(root, '.git')),
|
|
322
|
+
products: merged,
|
|
323
|
+
summary: summarise(merged, path.basename(root)),
|
|
324
|
+
packageManager: manager,
|
|
325
|
+
workspaces,
|
|
326
|
+
members: members.map((m) => ({ name: m.name, root: m.root })),
|
|
327
|
+
languages: await languagesIn(root),
|
|
328
|
+
tests,
|
|
329
|
+
scripts: scriptsOf(pkg?.scripts ?? {}),
|
|
330
|
+
doors,
|
|
331
|
+
routes,
|
|
332
|
+
pages,
|
|
333
|
+
containers,
|
|
334
|
+
evidence: dedupeClues(evidence),
|
|
335
|
+
unsure,
|
|
336
|
+
durationMs: Date.now() - started,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The same thing, said out loud, short enough to paste into a message to a person.
|
|
342
|
+
*
|
|
343
|
+
* @param {ProjectShape} shape
|
|
344
|
+
* @returns {string[]}
|
|
345
|
+
*/
|
|
346
|
+
export function describeShape(shape) {
|
|
347
|
+
/** @type {string[]} */
|
|
348
|
+
const lines = [];
|
|
349
|
+
lines.push(shape.summary);
|
|
350
|
+
lines.push('');
|
|
351
|
+
for (const product of shape.products) {
|
|
352
|
+
const sure = product.confidence >= 0.8 ? '' : product.confidence >= 0.5 ? ' (fairly sure)' : ' (a guess)';
|
|
353
|
+
lines.push(`${product.name}${sure} — ${product.why}`);
|
|
354
|
+
if (product.built.found) lines.push(` built and ready: ${product.built.where}`);
|
|
355
|
+
for (const blocker of product.blockers) lines.push(` in the way: ${blocker}`);
|
|
356
|
+
}
|
|
357
|
+
if (shape.products.length > 0) lines.push('');
|
|
358
|
+
if (shape.tests.runner) {
|
|
359
|
+
const how = shape.tests.command ? `, run by \`${short(shape.tests.command)}\`` : '';
|
|
360
|
+
lines.push(`Its own tests: ${shape.tests.files} file${shape.tests.files === 1 ? '' : 's'} written with ${shape.tests.runner}${how}. Those are journeys this tool can borrow instead of inventing its own.`);
|
|
361
|
+
} else {
|
|
362
|
+
lines.push('No test suite was found, so every journey has to come from the code or from a recording.');
|
|
363
|
+
}
|
|
364
|
+
if (shape.doors.read) {
|
|
365
|
+
const many = (/** @type {number} */ n, /** @type {string} */ one, /** @type {string} */ lots) => `${n} ${n === 1 ? one : lots}`;
|
|
366
|
+
lines.push(`Read out of the code without running any of it: ${many(shape.doors.route, 'route', 'routes')}, ${many(shape.doors.ipc, 'private channel', 'private channels')}, ${many(shape.doors.export, 'exported name', 'exported names')}, ${many(shape.doors.command, 'command', 'commands')}.`);
|
|
367
|
+
}
|
|
368
|
+
for (const doubt of shape.unsure) lines.push(doubt);
|
|
369
|
+
return lines;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The single most important product, when something has to pick one — the front page of a
|
|
374
|
+
* report, the default when a command takes one name. It is the most certain, and ties break
|
|
375
|
+
* towards the one a person would name first.
|
|
376
|
+
*
|
|
377
|
+
* @param {ProjectShape} shape
|
|
378
|
+
* @returns {Product|null}
|
|
379
|
+
*/
|
|
380
|
+
export function mainProduct(shape) {
|
|
381
|
+
return shape.products[0] ?? null;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Working out the products in one folder
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* @param {object} input
|
|
390
|
+
* @param {string} input.root
|
|
391
|
+
* @param {string} input.where Relative folder, '.' for the project root.
|
|
392
|
+
* @param {{files: string[], dirs: string[]}} input.listing
|
|
393
|
+
* @param {any} input.pkg
|
|
394
|
+
* @param {ProjectShape['doors']} input.doors
|
|
395
|
+
* @param {ProjectShape['pages']} input.pages
|
|
396
|
+
* @param {{dockerfile: string|null, compose: string|null}} input.containers
|
|
397
|
+
* @param {Record<string, string>} input.scripts
|
|
398
|
+
* @param {Set<string>} input.available Adapters actually present in this copy of the tool.
|
|
399
|
+
* @returns {Promise<Product[]>}
|
|
400
|
+
*/
|
|
401
|
+
async function productsIn(input) {
|
|
402
|
+
const { root, where, listing, pkg, doors, pages, containers, scripts, available } = input;
|
|
403
|
+
const dir = path.join(root, where);
|
|
404
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
405
|
+
const has = (/** @type {string} */ name) => name in deps;
|
|
406
|
+
const file = (/** @type {string} */ name) => listing.files.includes(name);
|
|
407
|
+
const anyFile = (/** @type {RegExp} */ re) => listing.files.find((f) => re.test(f)) ?? null;
|
|
408
|
+
const folder = (/** @type {string} */ name) => listing.dirs.includes(name);
|
|
409
|
+
const at = (/** @type {string} */ rel) => (where === '.' ? rel : path.join(where, rel));
|
|
410
|
+
|
|
411
|
+
/** @type {Product[]} */
|
|
412
|
+
const found = [];
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {string} kind
|
|
416
|
+
* @param {object} spec
|
|
417
|
+
* @param {string} spec.name
|
|
418
|
+
* @param {number} spec.confidence
|
|
419
|
+
* @param {string} spec.why
|
|
420
|
+
* @param {Clue[]} spec.evidence
|
|
421
|
+
* @param {{found: boolean, where: string|null, how: string}} [spec.built]
|
|
422
|
+
* @param {string[]} [spec.blockers]
|
|
423
|
+
* @param {Record<string, any>} [spec.suggest]
|
|
424
|
+
*/
|
|
425
|
+
const add = (kind, spec) => {
|
|
426
|
+
const meta = PRODUCT_KINDS[kind];
|
|
427
|
+
found.push({
|
|
428
|
+
kind,
|
|
429
|
+
name: spec.name,
|
|
430
|
+
surface: meta.surface,
|
|
431
|
+
adapter: meta.adapter && available.has(meta.adapter) ? meta.adapter : null,
|
|
432
|
+
confidence: spec.confidence,
|
|
433
|
+
why: spec.why,
|
|
434
|
+
where,
|
|
435
|
+
evidence: spec.evidence,
|
|
436
|
+
built: spec.built ?? { found: false, where: null, how: 'nothing to build — it runs from source' },
|
|
437
|
+
blockers: spec.blockers ?? [],
|
|
438
|
+
suggest: spec.suggest,
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// ── Electron ──────────────────────────────────────────────────────────────
|
|
443
|
+
const builderFile = anyFile(/^electron-builder\.(ya?ml|json|js|cjs|ts)$/) ?? anyFile(/^forge\.config\.(js|cjs|mjs|ts)$/);
|
|
444
|
+
const electronish = has('electron') || Boolean(builderFile) || Boolean(pkg?.build?.appId);
|
|
445
|
+
if (electronish) {
|
|
446
|
+
const app = await findBuiltApp(dir);
|
|
447
|
+
/** @type {Clue[]} */
|
|
448
|
+
const clues = [];
|
|
449
|
+
if (has('electron')) clues.push({ where: at('package.json'), means: 'It depends on Electron, which is how desktop apps are built out of web code.' });
|
|
450
|
+
if (builderFile) clues.push({ where: at(builderFile), means: 'There is a packaging config, so this repository produces an installable desktop app.' });
|
|
451
|
+
if (pkg?.build?.appId) clues.push({ where: at('package.json'), means: `It has an application id (${String(pkg.build.appId)}), which only a packaged desktop app has.` });
|
|
452
|
+
if (app.where) clues.push({ where: path.relative(root, app.where), means: 'A built desktop app is sitting here already, so it can be opened and read straight away.' });
|
|
453
|
+
add('electron', {
|
|
454
|
+
name: 'the desktop app',
|
|
455
|
+
confidence: app.where ? 1 : 0.85,
|
|
456
|
+
why: app.where ? `It depends on Electron and a built app is already sitting at ${path.relative(root, app.where)}.` : 'It depends on Electron, so it produces a desktop app — but no built copy was found, so there is nothing to open yet.',
|
|
457
|
+
evidence: clues,
|
|
458
|
+
built: { found: Boolean(app.where), where: app.where ? path.relative(root, app.where) : null, how: app.how },
|
|
459
|
+
blockers: app.where ? [] : ['The app has not been built. A desktop app can only be checked once there is a built copy to open — build it the way you normally do, then point the settings at the result.'],
|
|
460
|
+
suggest: app.where ? { binary: path.relative(root, app.where) } : {},
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── iOS ───────────────────────────────────────────────────────────────────
|
|
465
|
+
const xcode = listing.dirs.find((d) => d.endsWith('.xcodeproj')) ?? listing.dirs.find((d) => d.endsWith('.xcworkspace')) ?? null;
|
|
466
|
+
const swiftPackage = file('Package.swift');
|
|
467
|
+
const podfile = file('Podfile');
|
|
468
|
+
const xcodegen = file('project.yml') && listing.dirs.some((d) => /^[A-Z]/.test(d));
|
|
469
|
+
// React Native and Expo are one codebase that becomes two apps. Both are reported, because
|
|
470
|
+
// reporting one would leave the other silently unchecked.
|
|
471
|
+
const reactNative = has('react-native') || has('expo');
|
|
472
|
+
if (xcode || (swiftPackage && (podfile || folder('Sources'))) || (podfile && !xcode) || (xcodegen && podfile) || reactNative) {
|
|
473
|
+
/** @type {Clue[]} */
|
|
474
|
+
const clues = [];
|
|
475
|
+
if (xcode) clues.push({ where: at(xcode), means: 'An Xcode project, which is how an Apple app is built.' });
|
|
476
|
+
if (swiftPackage) clues.push({ where: at('Package.swift'), means: 'Swift source organised as a package.' });
|
|
477
|
+
if (podfile) clues.push({ where: at('Podfile'), means: 'CocoaPods dependencies, which are used by iOS apps.' });
|
|
478
|
+
if (reactNative) clues.push({ where: at('package.json'), means: 'It depends on React Native, so one codebase becomes both an iPhone app and an Android app.' });
|
|
479
|
+
const ipa = await findFirst(dir, /\.(ipa|app)$/, ['build', 'DerivedData', 'Products']);
|
|
480
|
+
add('ios', {
|
|
481
|
+
name: 'the iPhone app',
|
|
482
|
+
confidence: xcode ? 1 : reactNative ? 0.8 : 0.6,
|
|
483
|
+
why: xcode ? `There is an Xcode project at ${at(xcode)}.` : reactNative ? 'It depends on React Native, which builds an iPhone app.' : 'There is Swift and iOS tooling here, though no Xcode project was found in the usual place.',
|
|
484
|
+
evidence: clues,
|
|
485
|
+
built: { found: Boolean(ipa), where: ipa ? path.relative(root, ipa) : null, how: ipa ? 'a built app was found' : 'nothing built was found' },
|
|
486
|
+
blockers: available.has('ios')
|
|
487
|
+
? ['It runs on the simulator. Two builds on a real phone in your hand can never be compared side by side, on any machine.']
|
|
488
|
+
: ['Nothing in this copy of the tool can drive an iPhone app yet. When it can, it will run on the simulator; two builds on a real phone in your hand can never be compared side by side.'],
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── Android ───────────────────────────────────────────────────────────────
|
|
493
|
+
const gradle = anyFile(/^build\.gradle(\.kts)?$/);
|
|
494
|
+
const gradlew = file('gradlew');
|
|
495
|
+
const manifest = folder('app') && fs.existsSync(path.join(dir, 'app', 'src', 'main', 'AndroidManifest.xml'));
|
|
496
|
+
if ((gradle && gradlew) || manifest || reactNative) {
|
|
497
|
+
/** @type {Clue[]} */
|
|
498
|
+
const clues = [];
|
|
499
|
+
if (gradle) clues.push({ where: at(gradle), means: 'A Gradle build, which is how an Android app is built.' });
|
|
500
|
+
if (manifest) clues.push({ where: at('app/src/main/AndroidManifest.xml'), means: 'An Android manifest, which only an Android app has.' });
|
|
501
|
+
if (reactNative) clues.push({ where: at('package.json'), means: 'It depends on React Native, so the same codebase also becomes an Android app.' });
|
|
502
|
+
const apk = await findFirst(dir, /\.(apk|aab)$/, ['build', 'app']);
|
|
503
|
+
add('android', {
|
|
504
|
+
name: 'the Android app',
|
|
505
|
+
confidence: manifest ? 1 : gradle && gradlew ? 0.8 : 0.7,
|
|
506
|
+
why: manifest ? 'There is an Android manifest and a Gradle build here.' : gradle && gradlew ? 'There is a Gradle build with a wrapper script, which is the shape of an Android project.' : 'It depends on React Native, which builds an Android app.',
|
|
507
|
+
evidence: clues,
|
|
508
|
+
built: { found: Boolean(apk), where: apk ? path.relative(root, apk) : null, how: apk ? 'a built package was found' : 'nothing built was found' },
|
|
509
|
+
suggest: apk ? { apk: path.relative(root, apk) } : {},
|
|
510
|
+
blockers: available.has('android')
|
|
511
|
+
? ['It runs on an emulator. Whether two emulator snapshots restore identically is unproven, so a run says which mode it used.']
|
|
512
|
+
: ['Nothing in this copy of the tool can drive an Android app yet. When it can, it will run on an emulator against the stored record.'],
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── Native desktop that is not Electron ───────────────────────────────────
|
|
517
|
+
if (folder('src-tauri') || (file('Cargo.toml') && folder('src-tauri'))) {
|
|
518
|
+
add('desktopNative', {
|
|
519
|
+
name: 'the Tauri desktop app',
|
|
520
|
+
confidence: 0.9,
|
|
521
|
+
why: 'There is a src-tauri folder, which is how a Tauri desktop app is built.',
|
|
522
|
+
evidence: [{ where: at('src-tauri'), means: 'Tauri wraps a web front end in a native window, so the window is not an Electron one.' }],
|
|
523
|
+
blockers: available.has('windows')
|
|
524
|
+
? ['A native window can only be read from the operating system it runs on, so this needs a machine running that system — a reachable SSH host counts.']
|
|
525
|
+
: ['A native desktop window can only be read from the operating system it runs on, and nothing in this copy of the tool drives one yet.'],
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Web ───────────────────────────────────────────────────────────────────
|
|
530
|
+
// Two tiers, because they are two different strengths of evidence. The first list only
|
|
531
|
+
// ever builds websites, so finding one settles the question. The second is a rendering
|
|
532
|
+
// library that turns up inside desktop apps and phone apps just as often, so on its own it
|
|
533
|
+
// is a hint and the confidence says so.
|
|
534
|
+
const onlyBuildsWebsites = ['next', 'nuxt', 'astro', '@remix-run/react', '@sveltejs/kit', 'gatsby', '11ty', '@11ty/eleventy'].find(has) ?? null;
|
|
535
|
+
const webFramework = onlyBuildsWebsites ?? (['vue', 'svelte', 'solid-js', 'preact', 'react'].find(has) ?? null);
|
|
536
|
+
const bundler = ['vite', 'webpack', 'parcel', 'esbuild', 'rollup', '@rsbuild/core'].find(has) ?? null;
|
|
537
|
+
const indexHtml = file('index.html') || fs.existsSync(path.join(dir, 'public', 'index.html'));
|
|
538
|
+
const hostConfig = anyFile(/^(vercel|netlify|firebase|now|wrangler)\.(json|toml)$/);
|
|
539
|
+
const routeFolders = folder('pages') || (folder('app') && (fs.existsSync(path.join(dir, 'app', 'page.tsx')) || fs.existsSync(path.join(dir, 'app', 'page.jsx')) || fs.existsSync(path.join(dir, 'app', 'layout.tsx'))));
|
|
540
|
+
const webish = Boolean(webFramework) || indexHtml || Boolean(hostConfig) || routeFolders || pages.length > 0;
|
|
541
|
+
if (webish) {
|
|
542
|
+
/** @type {Clue[]} */
|
|
543
|
+
const clues = [];
|
|
544
|
+
if (webFramework) clues.push({ where: at('package.json'), means: `It depends on ${webFramework}, which builds web pages.` });
|
|
545
|
+
if (bundler) clues.push({ where: at('package.json'), means: `It is bundled with ${bundler}, so there is a build step and usually a dev server.` });
|
|
546
|
+
if (indexHtml) clues.push({ where: at(file('index.html') ? 'index.html' : 'public/index.html'), means: 'There is a page to open.' });
|
|
547
|
+
if (hostConfig) clues.push({ where: at(hostConfig), means: 'It is configured for a hosting service, so it is deployed as a site.' });
|
|
548
|
+
if (pages.length > 0) clues.push({ where: 'the page folders', means: `${pages.length} page address${pages.length === 1 ? '' : 'es'} were read out of folder names.` });
|
|
549
|
+
|
|
550
|
+
// An Electron repository almost always has web code in it — that IS the window. Calling
|
|
551
|
+
// the window a second, separate website is how one product gets checked twice and the
|
|
552
|
+
// report doubles in size for nothing. So it only counts as a website of its own when it
|
|
553
|
+
// has its own pages, its own host config, or its own package.
|
|
554
|
+
const isTheElectronWindow = electronish && where === '.' && !hostConfig && pages.length === 0;
|
|
555
|
+
if (!isTheElectronWindow) {
|
|
556
|
+
const dev = scripts.dev ?? scripts.start ?? scripts.serve ?? null;
|
|
557
|
+
const start = dev ? inFolder(npmRun(scripts, dev), where) : null;
|
|
558
|
+
// A site made of plain .html files has no framework and no dev server, and every one
|
|
559
|
+
// of those files is a page somebody can open. Listing them is what turns "there is a
|
|
560
|
+
// website here" into journeys that can actually be walked.
|
|
561
|
+
const flat = listing.files.filter((f) => f.endsWith('.html')).map((f) => (f === 'index.html' ? '/' : `/${f}`));
|
|
562
|
+
if (flat.length > 1) clues.push({ where: at('*.html'), means: `${flat.length} pages are plain HTML files sitting in this folder.` });
|
|
563
|
+
add('web', {
|
|
564
|
+
name: where === '.' ? 'the website' : `the website in ${where}/`,
|
|
565
|
+
confidence: onlyBuildsWebsites || ((webFramework || indexHtml) && (bundler || hostConfig || pages.length > 0)) ? 0.95 : 0.6,
|
|
566
|
+
why: [
|
|
567
|
+
webFramework ? `It uses ${webFramework}` : 'There is a page here',
|
|
568
|
+
pages.length > 0 ? ` and ${pages.length} page address${pages.length === 1 ? '' : 'es'} were read out of the folder names` : '',
|
|
569
|
+
flat.length > 1 && pages.length === 0 ? ` and ${flat.length} more are plain HTML files` : '',
|
|
570
|
+
hostConfig ? `, it is set up to deploy to ${hostConfig.split('.')[0]}` : '',
|
|
571
|
+
start ? `, and \`${start}\` starts it` : '',
|
|
572
|
+
].join('') + '.',
|
|
573
|
+
evidence: clues,
|
|
574
|
+
blockers: start
|
|
575
|
+
? []
|
|
576
|
+
: ['There is no command that starts it, so each build cannot be booted on its own. Without that, both halves of a comparison would read the same running copy and prove nothing. A static site only needs a static file server — anything that serves this folder on the PORT it is given will do.'],
|
|
577
|
+
suggest: {
|
|
578
|
+
...(start ? { start } : {}),
|
|
579
|
+
...(flat.length > 0 && pages.length === 0 ? { screens: flat.map((url) => ({ name: url === '/' ? 'the front page' : url, url })) } : {}),
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ── Server ────────────────────────────────────────────────────────────────
|
|
586
|
+
const serverFramework = ['express', 'fastify', 'hono', 'koa', '@hapi/hapi', '@nestjs/core', 'polka', 'restify'].find(has) ?? null;
|
|
587
|
+
const serverish = Boolean(serverFramework) || doors.route > 0 || Boolean(containers.dockerfile && scripts.start);
|
|
588
|
+
if (serverish) {
|
|
589
|
+
/** @type {Clue[]} */
|
|
590
|
+
const clues = [];
|
|
591
|
+
if (serverFramework) clues.push({ where: at('package.json'), means: `It depends on ${serverFramework}, which serves requests.` });
|
|
592
|
+
if (doors.route > 0) clues.push({ where: 'the source', means: `${doors.route} route${doors.route === 1 ? '' : 's'} are declared in the code.` });
|
|
593
|
+
if (containers.dockerfile) clues.push({ where: containers.dockerfile, means: 'It ships as a container, so there is a known way to start it.' });
|
|
594
|
+
// Next.js and its cousins are a website first. Their API routes are real and worth
|
|
595
|
+
// checking, but calling the whole thing "a server" as well as "a website" would report
|
|
596
|
+
// one product twice.
|
|
597
|
+
const alreadyAWebsite = found.some((p) => p.kind === 'web') && !serverFramework;
|
|
598
|
+
if (!alreadyAWebsite) {
|
|
599
|
+
add('server', {
|
|
600
|
+
name: where === '.' ? 'the server' : `the server in ${where}/`,
|
|
601
|
+
confidence: serverFramework ? 0.9 : doors.route > 3 ? 0.6 : 0.4,
|
|
602
|
+
why: serverFramework ? `It uses ${serverFramework} and ${doors.route} route${doors.route === 1 ? '' : 's'} are written in the code.` : `${doors.route} route${doors.route === 1 ? '' : 's'} are written in the code, though no web framework is installed.`,
|
|
603
|
+
evidence: clues,
|
|
604
|
+
blockers: scripts.start ? [] : ['There is no command that starts it. The routes can be listed from the source without one, but none of them can be walked.'],
|
|
605
|
+
suggest: scripts.start ? { start: inFolder(npmRun(scripts, scripts.start), where) } : {},
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// ── Command-line tool ─────────────────────────────────────────────────────
|
|
611
|
+
const bins = pkg?.bin ? (typeof pkg.bin === 'string' ? [String(pkg.name ?? 'the command')] : Object.keys(pkg.bin)) : [];
|
|
612
|
+
if (bins.length > 0) {
|
|
613
|
+
add('cli', {
|
|
614
|
+
name: bins.length === 1 ? `the \`${bins[0]}\` command` : `${bins.length} command-line tools`,
|
|
615
|
+
confidence: 1,
|
|
616
|
+
why: `package.json installs ${bins.length === 1 ? `a command called \`${bins[0]}\`` : `${bins.length} commands: ${bins.join(', ')}`}.`,
|
|
617
|
+
evidence: [{ where: at('package.json'), means: 'The "bin" field is what makes a package installable as a command you can type.' }],
|
|
618
|
+
// `--help` and nothing else, deliberately. A command in package.json could deploy, could
|
|
619
|
+
// publish, could wipe a database — running one because it was there would be the tool
|
|
620
|
+
// causing the very kind of damage it exists to catch. Asking a command to describe
|
|
621
|
+
// itself is the one thing every command-line tool does safely.
|
|
622
|
+
suggest: { commands: bins.map((name) => ({ name: `${name} --help`, run: `${runnerFor(pkg, name)} --help`, describe: `ask ${name} to print its help, and compare every word of it` })) },
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ── Library ───────────────────────────────────────────────────────────────
|
|
627
|
+
// A package can be both. `staysfixed` itself installs a command AND publishes an entry
|
|
628
|
+
// other code imports, and checking only the command would leave every exported name
|
|
629
|
+
// unwatched — which is the whole `library` surface, gone quietly.
|
|
630
|
+
const publishes = Boolean(pkg?.exports || pkg?.main || pkg?.module) && pkg?.private !== true;
|
|
631
|
+
const declaresAnEntry = Boolean(pkg?.exports);
|
|
632
|
+
if (publishes && (bins.length === 0 || declaresAnEntry) && !electronish && !webish) {
|
|
633
|
+
const entry = entryPointOf(pkg);
|
|
634
|
+
add('library', {
|
|
635
|
+
name: 'the library other code imports',
|
|
636
|
+
confidence: declaresAnEntry ? 0.85 : 0.5,
|
|
637
|
+
why: declaresAnEntry ? 'It publishes an "exports" map, which is what other code imports.' : 'It has a main entry point and is not marked private, so other code can import it.',
|
|
638
|
+
evidence: [{ where: at('package.json'), means: `Other projects import it, entering at ${entry}.` }],
|
|
639
|
+
suggest: { imports: [{ name: 'the package entry', module: entry }] },
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ── Something real that nothing here can drive ────────────────────────────
|
|
644
|
+
const otherLanguage = file('Cargo.toml') ? 'Rust' : file('go.mod') ? 'Go' : file('pubspec.yaml') ? 'Flutter' : file('pyproject.toml') || file('requirements.txt') ? 'Python' : file('Gemfile') ? 'Ruby' : file('composer.json') ? 'PHP' : null;
|
|
645
|
+
if (otherLanguage && found.length === 0) {
|
|
646
|
+
add('other', {
|
|
647
|
+
name: `a ${otherLanguage} project`,
|
|
648
|
+
confidence: 0.8,
|
|
649
|
+
why: `${otherLanguage} project files are here, and no adapter in this tool can drive ${otherLanguage} yet.`,
|
|
650
|
+
evidence: [{ where: at(file('Cargo.toml') ? 'Cargo.toml' : file('go.mod') ? 'go.mod' : file('pubspec.yaml') ? 'pubspec.yaml' : file('Gemfile') ? 'Gemfile' : file('composer.json') ? 'composer.json' : 'pyproject.toml'), means: `This is how a ${otherLanguage} project declares itself.` }],
|
|
651
|
+
blockers: [`Nothing drives ${otherLanguage} directly. If it produces a command you can type, list it under "process" in the settings and the whole command-line half of this tool works on it today.`],
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return found;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
// Reading the repository
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* One directory listing, split into files and folders, read once and passed around.
|
|
664
|
+
* @param {string} dir
|
|
665
|
+
* @returns {Promise<{files: string[], dirs: string[]}>}
|
|
666
|
+
*/
|
|
667
|
+
async function listOnce(dir) {
|
|
668
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
669
|
+
let entries;
|
|
670
|
+
try {
|
|
671
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
672
|
+
} catch {
|
|
673
|
+
return { files: [], dirs: [] };
|
|
674
|
+
}
|
|
675
|
+
/** @type {string[]} */
|
|
676
|
+
const files = [];
|
|
677
|
+
/** @type {string[]} */
|
|
678
|
+
const dirs = [];
|
|
679
|
+
for (const entry of entries) {
|
|
680
|
+
if (entry.isDirectory()) dirs.push(entry.name);
|
|
681
|
+
else if (entry.isFile() || entry.isSymbolicLink()) files.push(entry.name);
|
|
682
|
+
}
|
|
683
|
+
files.sort();
|
|
684
|
+
dirs.sort();
|
|
685
|
+
return { files, dirs };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* @param {string} file
|
|
690
|
+
* @returns {Promise<any|null>}
|
|
691
|
+
*/
|
|
692
|
+
async function readJson(file) {
|
|
693
|
+
try {
|
|
694
|
+
return JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Which package manager installs this project, from the lockfile that is actually there.
|
|
702
|
+
* The lockfile is the truth: a `packageManager` field says what somebody INTENDED, and a
|
|
703
|
+
* lockfile says what happened.
|
|
704
|
+
*
|
|
705
|
+
* @param {{files: string[], dirs: string[]}} listing
|
|
706
|
+
* @param {any} pkg
|
|
707
|
+
* @returns {{name: string, lockfile: string|null, why: string}}
|
|
708
|
+
*/
|
|
709
|
+
function packageManagerOf(listing, pkg) {
|
|
710
|
+
/** @type {[string, string][]} */
|
|
711
|
+
const known = [
|
|
712
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
713
|
+
['yarn.lock', 'yarn'],
|
|
714
|
+
['bun.lockb', 'bun'],
|
|
715
|
+
['bun.lock', 'bun'],
|
|
716
|
+
['package-lock.json', 'npm'],
|
|
717
|
+
['npm-shrinkwrap.json', 'npm'],
|
|
718
|
+
];
|
|
719
|
+
for (const [lockfile, name] of known) {
|
|
720
|
+
if (listing.files.includes(lockfile)) return { name, lockfile, why: `${lockfile} is here, so this project is installed with ${name}.` };
|
|
721
|
+
}
|
|
722
|
+
const declared = typeof pkg?.packageManager === 'string' ? String(pkg.packageManager).split('@')[0] : null;
|
|
723
|
+
if (declared) return { name: declared, lockfile: null, why: `package.json asks for ${declared}, though no lockfile was found.` };
|
|
724
|
+
return { name: 'npm', lockfile: null, why: 'No lockfile was found, so npm is assumed.' };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Workspace globs, from either of the two places they live.
|
|
729
|
+
* @param {any} pkg
|
|
730
|
+
* @param {{files: string[], dirs: string[]}} listing
|
|
731
|
+
* @param {string} root
|
|
732
|
+
* @returns {string[]}
|
|
733
|
+
*/
|
|
734
|
+
function workspaceGlobsOf(pkg, listing, root) {
|
|
735
|
+
/** @type {string[]} */
|
|
736
|
+
const globs = [];
|
|
737
|
+
const declared = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
|
|
738
|
+
if (Array.isArray(declared)) globs.push(...declared.map(String));
|
|
739
|
+
if (listing.files.includes('pnpm-workspace.yaml')) {
|
|
740
|
+
try {
|
|
741
|
+
const text = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8');
|
|
742
|
+
for (const line of text.split('\n')) {
|
|
743
|
+
const found = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*$/.exec(line);
|
|
744
|
+
if (found) globs.push(found[1].trim());
|
|
745
|
+
}
|
|
746
|
+
} catch {
|
|
747
|
+
// An unreadable workspace file is not worth an error; the folder scan finds the
|
|
748
|
+
// members anyway, and finding them twice is harmless.
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return [...new Set(globs)].filter(Boolean);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* The sub-packages that really exist, from the workspace globs and from a plain look at the
|
|
756
|
+
* usual folders. Only the one level of glob everybody actually uses is expanded — `packages/*`
|
|
757
|
+
* and `apps/*` — because implementing a glob engine to find a folder that is right there
|
|
758
|
+
* would be a lot of code to answer a question `readdir` answers.
|
|
759
|
+
*
|
|
760
|
+
* @param {string} root
|
|
761
|
+
* @param {string[]} globs
|
|
762
|
+
* @param {{files: string[], dirs: string[]}} listing
|
|
763
|
+
* @returns {Promise<{name: string, root: string, pkg: any}[]>}
|
|
764
|
+
*/
|
|
765
|
+
async function findMembers(root, globs, listing) {
|
|
766
|
+
/** @type {Set<string>} */
|
|
767
|
+
const folders = new Set();
|
|
768
|
+
for (const glob of globs) {
|
|
769
|
+
if (glob.endsWith('/*') || glob.endsWith('/**')) {
|
|
770
|
+
const parent = glob.replace(/\/\*+$/, '');
|
|
771
|
+
const inner = await listOnce(path.join(root, parent));
|
|
772
|
+
for (const dir of inner.dirs) folders.add(path.join(parent, dir));
|
|
773
|
+
} else if (!glob.includes('*')) {
|
|
774
|
+
folders.add(glob);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
// A repository can hold a second package without ever declaring a workspace — Terminal
|
|
778
|
+
// Deck's web client is exactly that. One level of readdir finds it, and missing it would
|
|
779
|
+
// mean reporting a repository as one product when it makes two.
|
|
780
|
+
for (const dir of listing.dirs) {
|
|
781
|
+
if (SKIP_DIRS.has(dir) || dir.startsWith('.')) continue;
|
|
782
|
+
folders.add(dir);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** @type {{name: string, root: string, pkg: any}[]} */
|
|
786
|
+
const members = [];
|
|
787
|
+
for (const folder of [...folders].sort()) {
|
|
788
|
+
const pkg = await readJson(path.join(root, folder, 'package.json'));
|
|
789
|
+
if (!pkg) continue;
|
|
790
|
+
members.push({ name: String(pkg.name ?? folder), root: folder, pkg });
|
|
791
|
+
}
|
|
792
|
+
return members;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Every door in the source, counted AND named, using the same reader the contract channel
|
|
797
|
+
* uses so the number here and the number in a check can never disagree.
|
|
798
|
+
*
|
|
799
|
+
* @param {string} root
|
|
800
|
+
* @returns {Promise<{doors: ProjectShape['doors'], routes: ProjectShape['routes']}>}
|
|
801
|
+
*/
|
|
802
|
+
async function readTheSource(root) {
|
|
803
|
+
try {
|
|
804
|
+
const { readContract, readFileRoutes, readPackageCommands } = await import('./adapters/source.js');
|
|
805
|
+
const reading = await readContract({ root });
|
|
806
|
+
const fileRoutes = await readFileRoutes(root);
|
|
807
|
+
const commands = await readPackageCommands(root);
|
|
808
|
+
const doors = [...reading.doors, ...fileRoutes, ...commands];
|
|
809
|
+
/** @type {Record<string, number>} */
|
|
810
|
+
const counts = {};
|
|
811
|
+
for (const door of doors) counts[door.kind] = (counts[door.kind] ?? 0) + 1;
|
|
812
|
+
|
|
813
|
+
// The route names, capped: a settings file listing five thousand routes helps nobody, and
|
|
814
|
+
// the count above is the honest total either way.
|
|
815
|
+
/** @type {Map<string, {name: string, method: string, file: string}>} */
|
|
816
|
+
const routes = new Map();
|
|
817
|
+
for (const door of doors) {
|
|
818
|
+
if (door.kind !== 'route') continue;
|
|
819
|
+
const method = door.detail === 'MOUNT' || door.detail === 'ANY' ? 'GET' : door.detail;
|
|
820
|
+
const key = `${method} ${door.name}`;
|
|
821
|
+
if (!routes.has(key)) routes.set(key, { name: door.name, method, file: door.file });
|
|
822
|
+
if (routes.size >= 200) break;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
return {
|
|
826
|
+
doors: {
|
|
827
|
+
ipc: counts.ipc ?? 0,
|
|
828
|
+
route: counts.route ?? 0,
|
|
829
|
+
export: counts.export ?? 0,
|
|
830
|
+
command: counts.command ?? 0,
|
|
831
|
+
env: counts.env ?? 0,
|
|
832
|
+
unnamed: reading.report.unnamed,
|
|
833
|
+
filesRead: reading.report.filesRead,
|
|
834
|
+
read: true,
|
|
835
|
+
why: reading.report.filesRead === 1
|
|
836
|
+
? '1 source file was read without running it.'
|
|
837
|
+
: `${reading.report.filesRead} source files were read without running any of them.`,
|
|
838
|
+
},
|
|
839
|
+
routes: [...routes.values()],
|
|
840
|
+
};
|
|
841
|
+
} catch (error) {
|
|
842
|
+
return {
|
|
843
|
+
doors: { ...notRead(), why: `The source could not be read: ${error instanceof Error ? error.message : String(error)}` },
|
|
844
|
+
routes: [],
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** @returns {ProjectShape['doors']} */
|
|
850
|
+
function notRead() {
|
|
851
|
+
return { ipc: 0, route: 0, export: 0, command: 0, env: 0, unnamed: 0, filesRead: 0, read: false, why: 'The source was not read.' };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* The page addresses a framework builds out of folder names, which no amount of reading
|
|
856
|
+
* calls will ever find.
|
|
857
|
+
*
|
|
858
|
+
* @param {string} root
|
|
859
|
+
* @returns {Promise<ProjectShape['pages']>}
|
|
860
|
+
*/
|
|
861
|
+
async function readThePages(root) {
|
|
862
|
+
try {
|
|
863
|
+
const { readPageRoutes } = await import('./adapters/web.js');
|
|
864
|
+
return await readPageRoutes(root);
|
|
865
|
+
} catch {
|
|
866
|
+
return [];
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* The project's own test suite: what runs it, how many there are, and where they live.
|
|
872
|
+
*
|
|
873
|
+
* This is the most valuable thing in the whole detection, and it is worth saying why. A test
|
|
874
|
+
* suite is a set of journeys somebody already wrote, already keeps working, and already
|
|
875
|
+
* trusts. Borrowing them is free. Inventing journeys is not.
|
|
876
|
+
*
|
|
877
|
+
* @param {string} root
|
|
878
|
+
* @param {any} pkg
|
|
879
|
+
* @param {{files: string[], dirs: string[]}} listing
|
|
880
|
+
* @returns {Promise<ProjectShape['tests']>}
|
|
881
|
+
*/
|
|
882
|
+
async function findTests(root, pkg, listing) {
|
|
883
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
884
|
+
const command = typeof pkg?.scripts?.test === 'string' ? String(pkg.scripts.test) : null;
|
|
885
|
+
|
|
886
|
+
/** @type {string|null} */
|
|
887
|
+
let runner = null;
|
|
888
|
+
for (const [name, label] of /** @type {[string, string][]} */ ([
|
|
889
|
+
['vitest', 'Vitest'], ['jest', 'Jest'], ['mocha', 'Mocha'], ['ava', 'AVA'],
|
|
890
|
+
['@playwright/test', 'Playwright Test'], ['cypress', 'Cypress'], ['tap', 'tap'], ['uvu', 'uvu'], ['@japa/runner', 'Japa'],
|
|
891
|
+
])) {
|
|
892
|
+
if (name in deps) { runner = label; break; }
|
|
893
|
+
}
|
|
894
|
+
// Node's own runner is often buried inside a wrapper script rather than typed plainly, so
|
|
895
|
+
// the flag is what is looked for, not the shape of the line around it.
|
|
896
|
+
if (!runner && command && /(^|\s|')--test(\s|'|$)|node:test/.test(command)) runner = "Node's own test runner";
|
|
897
|
+
if (!runner && command && command.trim() !== '' && !/^echo\b/.test(command)) runner = 'a command in package.json';
|
|
898
|
+
|
|
899
|
+
const folders = listing.dirs.filter((d) => ['test', 'tests', '__tests__', 'spec', 'e2e', 'cypress'].includes(d));
|
|
900
|
+
const files = await countMatching(root, /\.(test|spec)\.[cm]?[jt]sx?$/);
|
|
901
|
+
return {
|
|
902
|
+
runner,
|
|
903
|
+
command,
|
|
904
|
+
files,
|
|
905
|
+
folders,
|
|
906
|
+
why: runner
|
|
907
|
+
? `${files} test file${files === 1 ? '' : 's'} were found, run with ${runner}. Each one is a journey this tool can walk under instrumentation instead of inventing its own.`
|
|
908
|
+
: 'No test runner was found, so every journey has to come from reading the code or from a recording.',
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* How many files in this project match a pattern. Capped, and the cap is real: a repository
|
|
914
|
+
* with a hundred thousand files is not worth two seconds to count its tests.
|
|
915
|
+
*
|
|
916
|
+
* @param {string} root
|
|
917
|
+
* @param {RegExp} pattern
|
|
918
|
+
* @returns {Promise<number>}
|
|
919
|
+
*/
|
|
920
|
+
async function countMatching(root, pattern) {
|
|
921
|
+
let count = 0;
|
|
922
|
+
let seen = 0;
|
|
923
|
+
/** @param {string} dir */
|
|
924
|
+
const walk = async (dir) => {
|
|
925
|
+
if (seen > MOST_FILES) return;
|
|
926
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
927
|
+
let entries;
|
|
928
|
+
try {
|
|
929
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
930
|
+
} catch {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
for (const entry of entries) {
|
|
934
|
+
if (seen > MOST_FILES) return;
|
|
935
|
+
if (entry.name.startsWith('.')) continue;
|
|
936
|
+
if (entry.isSymbolicLink()) continue;
|
|
937
|
+
if (entry.isDirectory()) {
|
|
938
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(path.join(dir, entry.name));
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
seen++;
|
|
942
|
+
if (pattern.test(entry.name)) count++;
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
await walk(root);
|
|
946
|
+
return count;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* The first file under here matching a pattern, looking only in the folders named — because
|
|
951
|
+
* a built artifact is always in one of four places, and searching a whole repository for a
|
|
952
|
+
* `.apk` is how a detector takes twenty seconds.
|
|
953
|
+
*
|
|
954
|
+
* @param {string} dir
|
|
955
|
+
* @param {RegExp} pattern
|
|
956
|
+
* @param {string[]} folders
|
|
957
|
+
* @returns {Promise<string|null>}
|
|
958
|
+
*/
|
|
959
|
+
async function findFirst(dir, pattern, folders) {
|
|
960
|
+
/** @type {string[]} */
|
|
961
|
+
const queue = folders.map((f) => path.join(dir, f));
|
|
962
|
+
let looked = 0;
|
|
963
|
+
while (queue.length > 0 && looked < 400) {
|
|
964
|
+
const here = /** @type {string} */ (queue.shift());
|
|
965
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
966
|
+
let entries;
|
|
967
|
+
try {
|
|
968
|
+
entries = await fsp.readdir(here, { withFileTypes: true });
|
|
969
|
+
} catch {
|
|
970
|
+
continue;
|
|
971
|
+
}
|
|
972
|
+
looked++;
|
|
973
|
+
for (const entry of entries) {
|
|
974
|
+
const full = path.join(here, entry.name);
|
|
975
|
+
if (pattern.test(entry.name)) return full;
|
|
976
|
+
if (entry.isDirectory() && !entry.name.startsWith('.') && !SKIP_DIRS.has(entry.name)) queue.push(full);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* A built desktop app, in the folders packagers actually use.
|
|
984
|
+
*
|
|
985
|
+
* Deliberately its own function rather than a call into the Electron adapter: detection has
|
|
986
|
+
* to work on a project the Electron adapter would refuse, and it must never depend on an
|
|
987
|
+
* adapter being loadable to say what a repository is.
|
|
988
|
+
*
|
|
989
|
+
* @param {string} dir
|
|
990
|
+
* @returns {Promise<{where: string|null, how: string}>}
|
|
991
|
+
*/
|
|
992
|
+
async function findBuiltApp(dir) {
|
|
993
|
+
const suffix = process.platform === 'darwin' ? '.app' : process.platform === 'win32' ? '.exe' : '.AppImage';
|
|
994
|
+
for (const folder of ['out', 'dist', 'release', 'build']) {
|
|
995
|
+
const here = path.join(dir, folder);
|
|
996
|
+
const listing = await listOnce(here);
|
|
997
|
+
for (const name of [...listing.dirs, ...listing.files]) {
|
|
998
|
+
if (name.endsWith(suffix)) return { where: path.join(here, name), how: `it is built and sitting in ${folder}/` };
|
|
999
|
+
}
|
|
1000
|
+
// electron-builder puts the app one level down, in a folder named after the platform.
|
|
1001
|
+
for (const inner of listing.dirs) {
|
|
1002
|
+
const deeper = await listOnce(path.join(here, inner));
|
|
1003
|
+
for (const name of [...deeper.dirs, ...deeper.files]) {
|
|
1004
|
+
if (name.endsWith(suffix)) return { where: path.join(here, inner, name), how: `it is built and sitting in ${folder}/${inner}/` };
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
return { where: null, how: 'no built app was found in out/, dist/, release/ or build/' };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/**
|
|
1012
|
+
* Which languages this repository is written in, most-used first, by counting extensions.
|
|
1013
|
+
*
|
|
1014
|
+
* Three files of a language before it is named at all: one stray `.py` script in a
|
|
1015
|
+
* JavaScript repository is not "a Python project", and a list that says it is makes every
|
|
1016
|
+
* other line on the report less believable.
|
|
1017
|
+
*
|
|
1018
|
+
* @param {string} root
|
|
1019
|
+
* @returns {Promise<string[]>}
|
|
1020
|
+
*/
|
|
1021
|
+
async function languagesIn(root) {
|
|
1022
|
+
/** @type {Record<string, string>} */
|
|
1023
|
+
const byExtension = {
|
|
1024
|
+
'.ts': 'TypeScript', '.tsx': 'TypeScript', '.js': 'JavaScript', '.jsx': 'JavaScript',
|
|
1025
|
+
'.mjs': 'JavaScript', '.cjs': 'JavaScript', '.swift': 'Swift', '.kt': 'Kotlin',
|
|
1026
|
+
'.java': 'Java', '.go': 'Go', '.rs': 'Rust', '.py': 'Python', '.rb': 'Ruby',
|
|
1027
|
+
'.php': 'PHP', '.cs': 'C#', '.dart': 'Dart', '.m': 'Objective-C', '.mm': 'Objective-C',
|
|
1028
|
+
};
|
|
1029
|
+
/** @type {Record<string, number>} */
|
|
1030
|
+
const counts = {};
|
|
1031
|
+
let seen = 0;
|
|
1032
|
+
/** @param {string} dir */
|
|
1033
|
+
const walk = async (dir) => {
|
|
1034
|
+
if (seen > MOST_FILES) return;
|
|
1035
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
1036
|
+
let entries;
|
|
1037
|
+
try {
|
|
1038
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
1039
|
+
} catch {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
for (const entry of entries) {
|
|
1043
|
+
if (seen > MOST_FILES) return;
|
|
1044
|
+
if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
|
|
1045
|
+
if (entry.isDirectory()) {
|
|
1046
|
+
if (!SKIP_DIRS.has(entry.name)) await walk(path.join(dir, entry.name));
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1049
|
+
seen++;
|
|
1050
|
+
const language = byExtension[path.extname(entry.name)];
|
|
1051
|
+
if (language) counts[language] = (counts[language] ?? 0) + 1;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
await walk(root);
|
|
1055
|
+
return Object.entries(counts)
|
|
1056
|
+
.sort((a, b) => b[1] - a[1])
|
|
1057
|
+
.filter(([, n]) => n >= 3)
|
|
1058
|
+
.map(([language]) => language);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ---------------------------------------------------------------------------
|
|
1062
|
+
// Tidying up
|
|
1063
|
+
// ---------------------------------------------------------------------------
|
|
1064
|
+
|
|
1065
|
+
/**
|
|
1066
|
+
* The scripts that mean something to this tool, picked out of the pile.
|
|
1067
|
+
* @param {Record<string, string>} scripts
|
|
1068
|
+
* @returns {ProjectShape['scripts']}
|
|
1069
|
+
*/
|
|
1070
|
+
function scriptsOf(scripts) {
|
|
1071
|
+
/** @param {string[]} names */
|
|
1072
|
+
const first = (names) => names.map((n) => (typeof scripts[n] === 'string' ? `npm run ${n}` : null)).find(Boolean) ?? null;
|
|
1073
|
+
return {
|
|
1074
|
+
dev: first(['dev', 'develop', 'serve', 'watch']),
|
|
1075
|
+
start: first(['start', 'serve', 'dev']),
|
|
1076
|
+
build: first(['build', 'compile', 'bundle']),
|
|
1077
|
+
test: first(['test']),
|
|
1078
|
+
typecheck: first(['typecheck', 'types', 'tsc', 'check-types']),
|
|
1079
|
+
package: first(['package', 'dist', 'pack', 'make']),
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* `npm run dev` when the thing named is a script, and the command itself when it is not.
|
|
1085
|
+
* @param {Record<string, string>} scripts
|
|
1086
|
+
* @param {string} candidate
|
|
1087
|
+
* @returns {string}
|
|
1088
|
+
*/
|
|
1089
|
+
function npmRun(scripts, candidate) {
|
|
1090
|
+
if (candidate.startsWith('npm run ') || candidate.startsWith('npm ')) return candidate;
|
|
1091
|
+
const name = Object.keys(scripts).find((key) => scripts[key] === candidate);
|
|
1092
|
+
return name ? `npm run ${name}` : candidate;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* A command that runs in a sub-folder, written so it works from the project root.
|
|
1097
|
+
*
|
|
1098
|
+
* No adapter takes a "which folder" setting — they all run the command from a scratch copy of
|
|
1099
|
+
* the whole project — so the folder has to be part of the command itself. Putting it in a
|
|
1100
|
+
* setting nobody reads would look right in the file and silently start the wrong thing.
|
|
1101
|
+
*
|
|
1102
|
+
* @param {string} command
|
|
1103
|
+
* @param {string} where
|
|
1104
|
+
* @returns {string}
|
|
1105
|
+
*/
|
|
1106
|
+
function inFolder(command, where) {
|
|
1107
|
+
return where === '.' ? command : `cd ${where} && ${command}`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* How you would actually run one of this package's commands, without installing it globally.
|
|
1112
|
+
* @param {any} pkg
|
|
1113
|
+
* @param {string} name
|
|
1114
|
+
* @returns {string}
|
|
1115
|
+
*/
|
|
1116
|
+
function runnerFor(pkg, name) {
|
|
1117
|
+
const bin = typeof pkg?.bin === 'string' ? pkg.bin : pkg?.bin?.[name];
|
|
1118
|
+
return typeof bin === 'string' ? `node ${bin}` : `npx ${name}`;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* One product per kind per folder, keeping the most confident of any duplicates, ranked so
|
|
1123
|
+
* the thing a person would name first comes first.
|
|
1124
|
+
*
|
|
1125
|
+
* @param {Product[]} products
|
|
1126
|
+
* @returns {Product[]}
|
|
1127
|
+
*/
|
|
1128
|
+
function mergeProducts(products) {
|
|
1129
|
+
/** @type {Map<string, Product>} */
|
|
1130
|
+
const best = new Map();
|
|
1131
|
+
for (const product of products) {
|
|
1132
|
+
const key = `${product.kind}:${product.where}`;
|
|
1133
|
+
const already = best.get(key);
|
|
1134
|
+
if (!already || product.confidence > already.confidence) best.set(key, product);
|
|
1135
|
+
}
|
|
1136
|
+
/** @type {Record<string, number>} */
|
|
1137
|
+
const order = { electron: 0, ios: 1, android: 2, web: 3, server: 4, cli: 5, library: 6, desktopNative: 7, container: 8, other: 9 };
|
|
1138
|
+
return [...best.values()].sort((a, b) => {
|
|
1139
|
+
const byConfidence = Math.round(b.confidence * 10) - Math.round(a.confidence * 10);
|
|
1140
|
+
if (byConfidence !== 0) return byConfidence;
|
|
1141
|
+
return (order[a.kind] ?? 99) - (order[b.kind] ?? 99);
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* One sentence naming everything this repository makes.
|
|
1147
|
+
* @param {Product[]} products
|
|
1148
|
+
* @param {string} fallbackName
|
|
1149
|
+
* @returns {string}
|
|
1150
|
+
*/
|
|
1151
|
+
function summarise(products, fallbackName) {
|
|
1152
|
+
if (products.length === 0) return `Nothing in ${fallbackName} looks like a product this tool knows how to watch yet.`;
|
|
1153
|
+
const names = products.map((p) => p.name);
|
|
1154
|
+
const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
1155
|
+
if (products.length === 1) return `This repository makes one thing: ${list}.`;
|
|
1156
|
+
return `This repository makes ${products.length} things at once: ${list}. A change in shared code can break any of them, so all of them are worth checking.`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Where `import` would actually enter this package.
|
|
1161
|
+
* @param {any} pkg
|
|
1162
|
+
* @returns {string}
|
|
1163
|
+
*/
|
|
1164
|
+
function entryPointOf(pkg) {
|
|
1165
|
+
const dot = pkg?.exports?.['.'] ?? pkg?.exports;
|
|
1166
|
+
if (typeof dot === 'string') return dot;
|
|
1167
|
+
if (dot && typeof dot === 'object') {
|
|
1168
|
+
for (const key of ['import', 'default', 'require', 'node']) {
|
|
1169
|
+
if (typeof dot[key] === 'string') return dot[key];
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (typeof pkg?.module === 'string') return pkg.module;
|
|
1173
|
+
if (typeof pkg?.main === 'string') return pkg.main;
|
|
1174
|
+
return '.';
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/**
|
|
1178
|
+
* A command short enough to read. The whole of it stays in the data; only the sentence is cut.
|
|
1179
|
+
* @param {string} text
|
|
1180
|
+
* @returns {string}
|
|
1181
|
+
*/
|
|
1182
|
+
function short(text) {
|
|
1183
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
1184
|
+
return flat.length <= 70 ? flat : `${flat.slice(0, 67)}...`;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* @param {Clue[]} clues
|
|
1189
|
+
* @returns {Clue[]}
|
|
1190
|
+
*/
|
|
1191
|
+
function dedupeClues(clues) {
|
|
1192
|
+
/** @type {Map<string, Clue>} */
|
|
1193
|
+
const seen = new Map();
|
|
1194
|
+
for (const clue of clues) {
|
|
1195
|
+
const key = `${clue.where}|${clue.means}`;
|
|
1196
|
+
if (!seen.has(key)) seen.set(key, clue);
|
|
1197
|
+
}
|
|
1198
|
+
return [...seen.values()];
|
|
1199
|
+
}
|