@mjasnikovs/pi-task 0.24.5 → 0.26.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/dist/task/artifact-closure.d.ts +71 -1
- package/dist/task/artifact-closure.js +274 -21
- package/dist/task/final-gate.d.ts +45 -0
- package/dist/task/final-gate.js +165 -1
- package/dist/task/serve-entry.d.ts +60 -0
- package/dist/task/serve-entry.js +343 -0
- package/package.json +1 -1
|
@@ -24,6 +24,12 @@ export interface ProducedOutputs {
|
|
|
24
24
|
opaque: Set<string>;
|
|
25
25
|
/** Dirs known to be created (mkdir, outdirs) — satisfies dir-kind refs. */
|
|
26
26
|
dirs: Set<string>;
|
|
27
|
+
/** Dirs a BUILD TOOL declared as its output location (`--outdir`, `Bun.build`
|
|
28
|
+
* outdir, a bundler's known dir, tsconfig/vite outDir). Strictly narrower
|
|
29
|
+
* than `dirs`: writing one file into `report/` makes `report` enumerable but
|
|
30
|
+
* never a build outdir. Generated-HTML scanning is gated on this set, so a
|
|
31
|
+
* one-off HTML report writer cannot pull its assets into the check. */
|
|
32
|
+
outdirs: Set<string>;
|
|
27
33
|
}
|
|
28
34
|
export declare function emptyProducers(): ProducedOutputs;
|
|
29
35
|
/** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
|
|
@@ -34,6 +40,30 @@ export declare function normalizeRefPath(raw: string): string | null;
|
|
|
34
40
|
export declare function extractJsRefs(source: string, referencer: string): RuntimeRef[];
|
|
35
41
|
/** Extract local-asset refs from one HTML source. */
|
|
36
42
|
export declare function extractHtmlRefs(source: string, referencer: string): RuntimeRef[];
|
|
43
|
+
/** One HTML document a source writes into a build output. */
|
|
44
|
+
export interface EmittedHtml {
|
|
45
|
+
/** Repo-relative path written to (`dist/index.html`). */
|
|
46
|
+
docPath: string;
|
|
47
|
+
/** The literal's raw text. */
|
|
48
|
+
html: string;
|
|
49
|
+
}
|
|
50
|
+
/** HTML documents this source writes: `Bun.write('x.html', <literal|ident>)` and
|
|
51
|
+
* the writeFile family. Only literal destinations, only HTML extensions. */
|
|
52
|
+
export declare function collectEmittedHtml(source: string): EmittedHtml[];
|
|
53
|
+
/**
|
|
54
|
+
* Asset references inside HTML this source GENERATES into a build output.
|
|
55
|
+
*
|
|
56
|
+
* Resolution, deterministic:
|
|
57
|
+
* • root-relative (`/app.css`) resolves against the build OUTDIR the document
|
|
58
|
+
* lands in (`dist/index.html` ⇒ `dist/`) — that dir is the server's static
|
|
59
|
+
* root by construction;
|
|
60
|
+
* • document-relative (`app.css`, `./assets/x.js`) resolves against the
|
|
61
|
+
* document's own directory;
|
|
62
|
+
* • schemes (`https:`, `data:`, `cid:`), protocol-relative `//host/x`, and bare
|
|
63
|
+
* `#fragment`s are dropped by normalizeRefPath;
|
|
64
|
+
* • a literal not written into a declared build outdir is never scanned at all.
|
|
65
|
+
*/
|
|
66
|
+
export declare function extractGeneratedHtmlRefs(source: string, referencer: string, prod: ProducedOutputs): RuntimeRef[];
|
|
37
67
|
/** Script entrypoints: `bun x.ts`, `bun run x.ts`, `node x.js`, `tsx x.ts` —
|
|
38
68
|
* the first path-shaped source arg of a runner command. */
|
|
39
69
|
export declare function extractScriptEntrypoints(body: string, referencer: string): RuntimeRef[];
|
|
@@ -58,12 +88,34 @@ export declare function collectProducersFromCommand(cmd: string, prod: ProducedO
|
|
|
58
88
|
* shape: tailwind's `-o dist/app.css` lives in a spawn array).
|
|
59
89
|
*/
|
|
60
90
|
export declare function collectProducersFromSource(source: string, prod: ProducedOutputs): void;
|
|
91
|
+
/**
|
|
92
|
+
* A WATCH/DEV script — one a production build never invokes.
|
|
93
|
+
*
|
|
94
|
+
* The load-bearing rule of nexttask 3 (mx5 run 18): `dist/app.css` had exactly
|
|
95
|
+
* one producer, `dev:css` (`@tailwindcss/cli … -o dist/app.css --watch`), a
|
|
96
|
+
* watch-mode dev script. The gate's own commands are `build`, `test`, `lint` —
|
|
97
|
+
* none of them runs it, so the shipped page loaded zero CSS while the closure
|
|
98
|
+
* table happily reported the file "produced". A production artifact closed only
|
|
99
|
+
* by a watch script is dangling by construction.
|
|
100
|
+
*
|
|
101
|
+
* Deterministic and name-or-flag based, exactly as pre-registered: a script
|
|
102
|
+
* whose NAME starts with `dev`/`watch`, or whose BODY carries `--watch`.
|
|
103
|
+
*/
|
|
104
|
+
export declare function isDevScript(name: string, body: string): boolean;
|
|
105
|
+
export interface ProducerOpts {
|
|
106
|
+
/**
|
|
107
|
+
* Drop watch/dev scripts (and the build files reachable only through them)
|
|
108
|
+
* from the producer table — the PRODUCTION view of what a release contains.
|
|
109
|
+
* Off by default: the shipped resolution path is unchanged by this task.
|
|
110
|
+
*/
|
|
111
|
+
excludeDevScripts?: boolean;
|
|
112
|
+
}
|
|
61
113
|
/**
|
|
62
114
|
* Discover everything the project's own machinery produces: package.json script
|
|
63
115
|
* bodies, build files those scripts run (plus conventional root build files),
|
|
64
116
|
* tsconfig/vite outDirs.
|
|
65
117
|
*/
|
|
66
|
-
export declare function discoverProducers(cwd: string): ProducedOutputs;
|
|
118
|
+
export declare function discoverProducers(cwd: string, opts?: ProducerOpts): ProducedOutputs;
|
|
67
119
|
/**
|
|
68
120
|
* Resolve refs against existence + producers. DANGLING requires POSITIVE
|
|
69
121
|
* evidence (see the module doc): the ref sits under an ENUMERATED output dir
|
|
@@ -79,6 +131,24 @@ export declare function resolveDanglingRefs(refs: RuntimeRef[], prod: ProducedOu
|
|
|
79
131
|
* producers (an app that writes its own cache file satisfies its own read).
|
|
80
132
|
*/
|
|
81
133
|
export declare function findDanglingArtifacts(cwd: string): DanglingRef[];
|
|
134
|
+
/** How a generated-HTML asset reference resolves — the STEP 0 measurement. */
|
|
135
|
+
export type GeneratedHtmlRefClass =
|
|
136
|
+
/** Produced by the production build (or already on disk). */
|
|
137
|
+
'produced'
|
|
138
|
+
/** Only a watch/dev script produces it — dangling for a production build. */
|
|
139
|
+
| 'dev-only'
|
|
140
|
+
/** Nothing produces it at all. */
|
|
141
|
+
| 'missing';
|
|
142
|
+
/** Classify one generated-HTML ref against both producer tables (STEP 0 / A/B
|
|
143
|
+
* reporting; the gate itself only needs the dangling verdict). */
|
|
144
|
+
export declare function classifyGeneratedHtmlRef(ref: RuntimeRef, full: ProducedOutputs, production: ProducedOutputs, exists: (rel: string) => boolean): GeneratedHtmlRefClass;
|
|
145
|
+
/** STEP 0 / A/B helper: every generated-HTML asset ref in a tree, with the
|
|
146
|
+
* producer tables it was measured against. Read-only, deterministic. */
|
|
147
|
+
export declare function collectGeneratedHtmlRefs(cwd: string): {
|
|
148
|
+
refs: RuntimeRef[];
|
|
149
|
+
full: ProducedOutputs;
|
|
150
|
+
production: ProducedOutputs;
|
|
151
|
+
};
|
|
82
152
|
/** Ranked-failure text for the final gate (names referencer + missing path). */
|
|
83
153
|
export declare function danglingGateFailureText(d: DanglingRef): string;
|
|
84
154
|
/** Does the spec LIST the file as its own artifact — a file-tree entry or a
|
|
@@ -43,7 +43,13 @@
|
|
|
43
43
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
44
44
|
import * as path from 'node:path';
|
|
45
45
|
export function emptyProducers() {
|
|
46
|
-
return {
|
|
46
|
+
return {
|
|
47
|
+
files: new Set(),
|
|
48
|
+
enumerable: new Map(),
|
|
49
|
+
opaque: new Set(),
|
|
50
|
+
dirs: new Set(),
|
|
51
|
+
outdirs: new Set()
|
|
52
|
+
};
|
|
47
53
|
}
|
|
48
54
|
/** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
|
|
49
55
|
* tails (HTML), trailing slash. Returns null when the literal is not a
|
|
@@ -147,6 +153,159 @@ export function extractHtmlRefs(source, referencer) {
|
|
|
147
153
|
}
|
|
148
154
|
return out;
|
|
149
155
|
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// GENERATED HTML (nexttask 3, mx5 run 18).
|
|
158
|
+
//
|
|
159
|
+
// The run-13 checker fired correctly on `src/server/index.ts → dist/index.html`;
|
|
160
|
+
// the autofix satisfied it by appending an HTML template literal to `build.ts`
|
|
161
|
+
// and `Bun.write`ing it — and that page pointed at `/app.css`, which `bun run
|
|
162
|
+
// build` never emits (only the watch-mode `dev:css` does). The re-run gate saw
|
|
163
|
+
// nothing, because the extractor scans HTML FILES and JS READS, never the HTML a
|
|
164
|
+
// source GENERATES. So one dangling reference was closed by creating another one
|
|
165
|
+
// indirection deeper.
|
|
166
|
+
//
|
|
167
|
+
// Scope discipline, the reason this does not become an FP machine: a literal is
|
|
168
|
+
// scanned ONLY when it reaches a write whose destination is an HTML file inside a
|
|
169
|
+
// directory a BUILD TOOL declared as its output (`prod.outdirs`). An email-body
|
|
170
|
+
// template (`~/hub/aiz-server/src/connections/mailTemplate.ts` — `export default
|
|
171
|
+
// \`<!doctype html>…\``) is never written to a build output and is therefore
|
|
172
|
+
// never scanned, `<img src="cid:logo">` and all.
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/** Asset attributes scanned in GENERATED HTML. Wider than HTML_PATTERNS (which
|
|
175
|
+
* keeps scanning on-disk .html files exactly as it always has) by the media
|
|
176
|
+
* tags nexttask 3 names. */
|
|
177
|
+
const GENERATED_HTML_PATTERNS = [
|
|
178
|
+
...HTML_PATTERNS,
|
|
179
|
+
{
|
|
180
|
+
re: /<(?:source|video|audio)\b[^>]*\bsrc\s*=\s*(['"])([^'"]+)\1/gi,
|
|
181
|
+
construct: 'media src',
|
|
182
|
+
kind: 'file'
|
|
183
|
+
}
|
|
184
|
+
];
|
|
185
|
+
/** Read a quoted/template literal starting at `i` (src[i] is the quote char).
|
|
186
|
+
* Returns the body and the index just past the closing quote, or null when
|
|
187
|
+
* unterminated. Escapes are honoured; a `${…}` hole is left in the text, where
|
|
188
|
+
* normalizeRefPath rejects it. */
|
|
189
|
+
function readLiteral(src, i) {
|
|
190
|
+
const q = src[i];
|
|
191
|
+
if (q !== '"' && q !== "'" && q !== '`')
|
|
192
|
+
return null;
|
|
193
|
+
let out = '';
|
|
194
|
+
for (let j = i + 1; j < src.length; j++) {
|
|
195
|
+
const c = src[j];
|
|
196
|
+
if (c === '\\') {
|
|
197
|
+
out += src[j + 1] ?? '';
|
|
198
|
+
j++;
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (c === q)
|
|
202
|
+
return { text: out, end: j + 1 };
|
|
203
|
+
if (c === '\n' && q !== '`')
|
|
204
|
+
return null; // unterminated single-line literal
|
|
205
|
+
out += c;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
/** `const NAME = <literal>` bindings (also let/var) — the mx5 build.ts shape is
|
|
210
|
+
* `const html = \`…\`` followed by `Bun.write('dist/index.html', html)`. */
|
|
211
|
+
function literalBindings(src) {
|
|
212
|
+
const out = new Map();
|
|
213
|
+
const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?=['"`])/g;
|
|
214
|
+
for (let m = re.exec(src); m !== null; m = re.exec(src)) {
|
|
215
|
+
const lit = readLiteral(src, m.index + m[0].length);
|
|
216
|
+
if (lit === null)
|
|
217
|
+
continue;
|
|
218
|
+
out.set(m[1], lit.text);
|
|
219
|
+
re.lastIndex = lit.end;
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
/** HTML documents this source writes: `Bun.write('x.html', <literal|ident>)` and
|
|
224
|
+
* the writeFile family. Only literal destinations, only HTML extensions. */
|
|
225
|
+
export function collectEmittedHtml(source) {
|
|
226
|
+
const src = stripCommentLines(source);
|
|
227
|
+
const bindings = literalBindings(src);
|
|
228
|
+
const out = [];
|
|
229
|
+
const writeRe = /\b(?:Bun\.write|writeFileSync|writeFile|fs\.promises\.writeFile)\(\s*(['"`])([^'"`\n]+)\1\s*,\s*/g;
|
|
230
|
+
for (let m = writeRe.exec(src); m !== null; m = writeRe.exec(src)) {
|
|
231
|
+
const docPath = normalizeRefPath(m[2]);
|
|
232
|
+
if (docPath === null || !SCAN_HTML_RE.test(docPath))
|
|
233
|
+
continue;
|
|
234
|
+
const at = m.index + m[0].length;
|
|
235
|
+
let html;
|
|
236
|
+
const lit = readLiteral(src, at);
|
|
237
|
+
if (lit !== null)
|
|
238
|
+
html = lit.text;
|
|
239
|
+
else {
|
|
240
|
+
const id = /^[A-Za-z_$][\w$]*/.exec(src.slice(at, at + 80));
|
|
241
|
+
if (id)
|
|
242
|
+
html = bindings.get(id[0]);
|
|
243
|
+
}
|
|
244
|
+
if (html === undefined || !html.includes('<'))
|
|
245
|
+
continue;
|
|
246
|
+
out.push({ docPath, html });
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
/** The narrowest build outdir containing `p`, or null when no build tool
|
|
251
|
+
* declared one that covers it. */
|
|
252
|
+
function containingOutdir(p, prod) {
|
|
253
|
+
let best = null;
|
|
254
|
+
for (const d of prod.outdirs) {
|
|
255
|
+
if (!underDir(p, d))
|
|
256
|
+
continue;
|
|
257
|
+
if (best === null || d.length > best.length)
|
|
258
|
+
best = d;
|
|
259
|
+
}
|
|
260
|
+
return best;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Asset references inside HTML this source GENERATES into a build output.
|
|
264
|
+
*
|
|
265
|
+
* Resolution, deterministic:
|
|
266
|
+
* • root-relative (`/app.css`) resolves against the build OUTDIR the document
|
|
267
|
+
* lands in (`dist/index.html` ⇒ `dist/`) — that dir is the server's static
|
|
268
|
+
* root by construction;
|
|
269
|
+
* • document-relative (`app.css`, `./assets/x.js`) resolves against the
|
|
270
|
+
* document's own directory;
|
|
271
|
+
* • schemes (`https:`, `data:`, `cid:`), protocol-relative `//host/x`, and bare
|
|
272
|
+
* `#fragment`s are dropped by normalizeRefPath;
|
|
273
|
+
* • a literal not written into a declared build outdir is never scanned at all.
|
|
274
|
+
*/
|
|
275
|
+
export function extractGeneratedHtmlRefs(source, referencer, prod) {
|
|
276
|
+
const out = [];
|
|
277
|
+
const seen = new Set();
|
|
278
|
+
for (const { docPath, html } of collectEmittedHtml(source)) {
|
|
279
|
+
const outdir = containingOutdir(docPath, prod);
|
|
280
|
+
if (outdir === null)
|
|
281
|
+
continue; // not a build artifact — out of scope
|
|
282
|
+
const docDir = path.posix.dirname(docPath);
|
|
283
|
+
for (const { re, construct, kind } of GENERATED_HTML_PATTERNS) {
|
|
284
|
+
re.lastIndex = 0;
|
|
285
|
+
for (let m = re.exec(html); m !== null; m = re.exec(html)) {
|
|
286
|
+
const raw = m[2];
|
|
287
|
+
const rootRelative = /^\/(?!\/)/.test(raw.trim());
|
|
288
|
+
const p = normalizeRefPath(raw);
|
|
289
|
+
if (p === null || !hasExt(p))
|
|
290
|
+
continue;
|
|
291
|
+
const base = rootRelative ? outdir : docDir;
|
|
292
|
+
const resolved = path.posix.normalize(base === '.' ? p : `${base}/${p}`);
|
|
293
|
+
if (resolved.startsWith('..'))
|
|
294
|
+
continue;
|
|
295
|
+
if (seen.has(resolved))
|
|
296
|
+
continue;
|
|
297
|
+
seen.add(resolved);
|
|
298
|
+
out.push({
|
|
299
|
+
path: resolved,
|
|
300
|
+
referencer,
|
|
301
|
+
construct: `${construct} in generated ${docPath}`,
|
|
302
|
+
kind
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
150
309
|
/** A path-shaped shell token: contains a separator or an extension, no shell
|
|
151
310
|
* metacharacters, not a flag. */
|
|
152
311
|
function isPathToken(t) {
|
|
@@ -230,8 +389,10 @@ export function collectProducersFromCommand(cmd, prod, opts = {}) {
|
|
|
230
389
|
let rest = cmd.replace(/2>&1|&>\s*\S+|2>\s*\S+/g, ' ');
|
|
231
390
|
for (const { re, dirs } of OPAQUE_TOOL_DIRS) {
|
|
232
391
|
if (re.test(rest))
|
|
233
|
-
for (const d of dirs)
|
|
392
|
+
for (const d of dirs) {
|
|
234
393
|
prod.opaque.add(d);
|
|
394
|
+
prod.outdirs.add(d);
|
|
395
|
+
}
|
|
235
396
|
}
|
|
236
397
|
// Redirect target.
|
|
237
398
|
rest = rest.replace(/>>?\s*([^\s&|;]+)/g, (_, f) => {
|
|
@@ -248,12 +409,16 @@ export function collectProducersFromCommand(cmd, prod, opts = {}) {
|
|
|
248
409
|
const p = normalizeRefPath(val);
|
|
249
410
|
if (p === null)
|
|
250
411
|
return ' ';
|
|
251
|
-
if (flag === '--outdir' || flag === '--out-dir')
|
|
412
|
+
if (flag === '--outdir' || flag === '--out-dir') {
|
|
252
413
|
addEnumerable(prod, p, []);
|
|
414
|
+
prod.outdirs.add(p);
|
|
415
|
+
}
|
|
253
416
|
else if (hasExt(p))
|
|
254
417
|
addFile(prod, p);
|
|
255
|
-
else
|
|
418
|
+
else {
|
|
256
419
|
addEnumerable(prod, p, []);
|
|
420
|
+
prod.outdirs.add(p);
|
|
421
|
+
}
|
|
257
422
|
return ' ';
|
|
258
423
|
});
|
|
259
424
|
const toks = rest.trim().split(/\s+/);
|
|
@@ -304,8 +469,10 @@ export function collectProducersFromCommand(cmd, prod, opts = {}) {
|
|
|
304
469
|
if (p !== null)
|
|
305
470
|
declaredDirs.push(p);
|
|
306
471
|
}
|
|
307
|
-
for (const d of declaredDirs)
|
|
472
|
+
for (const d of declaredDirs) {
|
|
308
473
|
addEnumerable(prod, d, sourceArgs.map(stem));
|
|
474
|
+
prod.outdirs.add(d);
|
|
475
|
+
}
|
|
309
476
|
// Unrecognized command: any leftover path token pointing INTO a directory
|
|
310
477
|
// makes that directory opaque — the tool may generate arbitrary files there.
|
|
311
478
|
if ((opts.escalate ?? true) && !KNOWN_NON_PRODUCING_RE.test(bin) && !/^@/.test(bin)) {
|
|
@@ -375,6 +542,7 @@ export function collectProducersFromSource(source, prod) {
|
|
|
375
542
|
const dir = normalizeRefPath(outdirM[2]);
|
|
376
543
|
if (dir === null)
|
|
377
544
|
continue;
|
|
545
|
+
prod.outdirs.add(dir);
|
|
378
546
|
if (/\bnaming:/.test(body)) {
|
|
379
547
|
prod.opaque.add(dir); // custom naming — outputs underivable
|
|
380
548
|
continue;
|
|
@@ -422,8 +590,10 @@ function collectTsconfigOutDirs(cwd, prod) {
|
|
|
422
590
|
const m = /"outDir"\s*:\s*"([^"]+)"/.exec(readFileSync(path.join(cwd, n), 'utf8'));
|
|
423
591
|
if (m) {
|
|
424
592
|
const p = normalizeRefPath(m[1]);
|
|
425
|
-
if (p !== null)
|
|
593
|
+
if (p !== null) {
|
|
426
594
|
prod.opaque.add(p);
|
|
595
|
+
prod.outdirs.add(p);
|
|
596
|
+
}
|
|
427
597
|
}
|
|
428
598
|
}
|
|
429
599
|
catch {
|
|
@@ -445,12 +615,15 @@ function collectBundlerConfigOutDirs(cwd, prod) {
|
|
|
445
615
|
if (!existsSync(f))
|
|
446
616
|
continue;
|
|
447
617
|
prod.opaque.add('dist');
|
|
618
|
+
prod.outdirs.add('dist');
|
|
448
619
|
try {
|
|
449
620
|
const m = /\boutDir:\s*(['"`])([^'"`\n]+)\1/.exec(readFileSync(f, 'utf8'));
|
|
450
621
|
if (m) {
|
|
451
622
|
const p = normalizeRefPath(m[2]);
|
|
452
|
-
if (p !== null)
|
|
623
|
+
if (p !== null) {
|
|
453
624
|
prod.opaque.add(p);
|
|
625
|
+
prod.outdirs.add(p);
|
|
626
|
+
}
|
|
454
627
|
}
|
|
455
628
|
}
|
|
456
629
|
catch {
|
|
@@ -468,14 +641,33 @@ function packageScripts(cwd) {
|
|
|
468
641
|
return {};
|
|
469
642
|
}
|
|
470
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* A WATCH/DEV script — one a production build never invokes.
|
|
646
|
+
*
|
|
647
|
+
* The load-bearing rule of nexttask 3 (mx5 run 18): `dist/app.css` had exactly
|
|
648
|
+
* one producer, `dev:css` (`@tailwindcss/cli … -o dist/app.css --watch`), a
|
|
649
|
+
* watch-mode dev script. The gate's own commands are `build`, `test`, `lint` —
|
|
650
|
+
* none of them runs it, so the shipped page loaded zero CSS while the closure
|
|
651
|
+
* table happily reported the file "produced". A production artifact closed only
|
|
652
|
+
* by a watch script is dangling by construction.
|
|
653
|
+
*
|
|
654
|
+
* Deterministic and name-or-flag based, exactly as pre-registered: a script
|
|
655
|
+
* whose NAME starts with `dev`/`watch`, or whose BODY carries `--watch`.
|
|
656
|
+
*/
|
|
657
|
+
export function isDevScript(name, body) {
|
|
658
|
+
return /^(?:dev|watch)\b|^(?:dev|watch)[:._-]/i.test(name) || /(?:^|\s)--watch\b/.test(body);
|
|
659
|
+
}
|
|
471
660
|
/**
|
|
472
661
|
* Discover everything the project's own machinery produces: package.json script
|
|
473
662
|
* bodies, build files those scripts run (plus conventional root build files),
|
|
474
663
|
* tsconfig/vite outDirs.
|
|
475
664
|
*/
|
|
476
|
-
export function discoverProducers(cwd) {
|
|
665
|
+
export function discoverProducers(cwd, opts = {}) {
|
|
477
666
|
const prod = emptyProducers();
|
|
478
|
-
const
|
|
667
|
+
const all = packageScripts(cwd);
|
|
668
|
+
const scripts = opts.excludeDevScripts === true ?
|
|
669
|
+
Object.fromEntries(Object.entries(all).filter(([n, b]) => !isDevScript(n, b)))
|
|
670
|
+
: all;
|
|
479
671
|
const buildFiles = new Set(['build.ts', 'build.js', 'build.mjs'].filter(f => existsSync(path.join(cwd, f))));
|
|
480
672
|
for (const body of Object.values(scripts)) {
|
|
481
673
|
for (const cmd of splitShellCommands(body)) {
|
|
@@ -504,6 +696,19 @@ export function discoverProducers(cwd) {
|
|
|
504
696
|
return prod;
|
|
505
697
|
}
|
|
506
698
|
const underDir = (p, dir) => p === dir || p.startsWith(dir + '/');
|
|
699
|
+
/** Is a FILE path positively produced — named exactly, under an opaque dir, or
|
|
700
|
+
* an enumerated stem of an enumerable outdir? */
|
|
701
|
+
function fileProduced(c, prod) {
|
|
702
|
+
if (prod.files.has(c))
|
|
703
|
+
return true;
|
|
704
|
+
if ([...prod.opaque].some(d => underDir(c, d)))
|
|
705
|
+
return true;
|
|
706
|
+
for (const [dir, stems] of prod.enumerable) {
|
|
707
|
+
if (underDir(path.posix.dirname(c), dir) && stems.has(stem(c)))
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
507
712
|
/**
|
|
508
713
|
* Resolve refs against existence + producers. DANGLING requires POSITIVE
|
|
509
714
|
* evidence (see the module doc): the ref sits under an ENUMERATED output dir
|
|
@@ -549,17 +754,7 @@ export function resolveDanglingRefs(refs, prod, exists) {
|
|
|
549
754
|
push(ref, `directory does not exist and no script or build step creates it`);
|
|
550
755
|
continue;
|
|
551
756
|
}
|
|
552
|
-
const isSatisfied = candidates.some(c =>
|
|
553
|
-
if (prod.files.has(c))
|
|
554
|
-
return true;
|
|
555
|
-
if ([...prod.opaque].some(d => underDir(c, d)))
|
|
556
|
-
return true;
|
|
557
|
-
for (const [dir, stems] of prod.enumerable) {
|
|
558
|
-
if (underDir(path.posix.dirname(c), dir) && stems.has(stem(c)))
|
|
559
|
-
return true;
|
|
560
|
-
}
|
|
561
|
-
return false;
|
|
562
|
-
});
|
|
757
|
+
const isSatisfied = candidates.some(c => fileProduced(c, prod));
|
|
563
758
|
if (isSatisfied)
|
|
564
759
|
continue;
|
|
565
760
|
// Positive-evidence branches:
|
|
@@ -644,7 +839,14 @@ function scanCandidates(cwd, prod) {
|
|
|
644
839
|
*/
|
|
645
840
|
export function findDanglingArtifacts(cwd) {
|
|
646
841
|
const prod = discoverProducers(cwd);
|
|
842
|
+
// Second, PRODUCTION-only table: identical machinery minus watch/dev scripts.
|
|
843
|
+
// Generated-HTML refs resolve against this one, so a file whose only producer
|
|
844
|
+
// is `dev:css --watch` does not close a reference the built page makes
|
|
845
|
+
// (nexttask 3's load-bearing rule). Everything else keeps resolving against
|
|
846
|
+
// the full table, so no existing finding moves.
|
|
847
|
+
const prodProduction = discoverProducers(cwd, { excludeDevScripts: true });
|
|
647
848
|
const refs = [];
|
|
849
|
+
const sources = [];
|
|
648
850
|
for (const rel of scanCandidates(cwd, prod)) {
|
|
649
851
|
let src;
|
|
650
852
|
try {
|
|
@@ -659,12 +861,63 @@ export function findDanglingArtifacts(cwd) {
|
|
|
659
861
|
else {
|
|
660
862
|
refs.push(...extractJsRefs(src, rel));
|
|
661
863
|
collectProducersFromSource(src, prod);
|
|
864
|
+
collectProducersFromSource(src, prodProduction);
|
|
865
|
+
sources.push({ rel, src });
|
|
662
866
|
}
|
|
663
867
|
}
|
|
664
868
|
for (const [name, body] of Object.entries(packageScripts(cwd))) {
|
|
665
869
|
refs.push(...extractScriptEntrypoints(body, `package.json scripts.${name}`));
|
|
666
870
|
}
|
|
667
|
-
|
|
871
|
+
const out = resolveDanglingRefs(refs, prod, rel => existsSync(path.join(cwd, rel)));
|
|
872
|
+
// Generated HTML runs in a SECOND pass: whether a literal counts as a build
|
|
873
|
+
// artifact depends on outdirs any source in the tree may have declared, so
|
|
874
|
+
// the producer table has to be complete first.
|
|
875
|
+
const generated = [];
|
|
876
|
+
for (const { rel, src } of sources) {
|
|
877
|
+
generated.push(...extractGeneratedHtmlRefs(src, rel, prodProduction));
|
|
878
|
+
}
|
|
879
|
+
const seen = new Set(out.map(d => `${d.referencer} ${d.path}`));
|
|
880
|
+
for (const d of resolveDanglingRefs(generated, prodProduction, rel => existsSync(path.join(cwd, rel)))) {
|
|
881
|
+
if (seen.has(`${d.referencer} ${d.path}`))
|
|
882
|
+
continue;
|
|
883
|
+
seen.add(`${d.referencer} ${d.path}`);
|
|
884
|
+
out.push(d);
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
/** Classify one generated-HTML ref against both producer tables (STEP 0 / A/B
|
|
889
|
+
* reporting; the gate itself only needs the dangling verdict). */
|
|
890
|
+
export function classifyGeneratedHtmlRef(ref, full, production, exists) {
|
|
891
|
+
if (exists(ref.path) || fileProduced(ref.path, production))
|
|
892
|
+
return 'produced';
|
|
893
|
+
if (fileProduced(ref.path, full))
|
|
894
|
+
return 'dev-only';
|
|
895
|
+
return 'missing';
|
|
896
|
+
}
|
|
897
|
+
/** STEP 0 / A/B helper: every generated-HTML asset ref in a tree, with the
|
|
898
|
+
* producer tables it was measured against. Read-only, deterministic. */
|
|
899
|
+
export function collectGeneratedHtmlRefs(cwd) {
|
|
900
|
+
const full = discoverProducers(cwd);
|
|
901
|
+
const production = discoverProducers(cwd, { excludeDevScripts: true });
|
|
902
|
+
const sources = [];
|
|
903
|
+
for (const rel of scanCandidates(cwd, full)) {
|
|
904
|
+
if (SCAN_HTML_RE.test(rel))
|
|
905
|
+
continue;
|
|
906
|
+
let src;
|
|
907
|
+
try {
|
|
908
|
+
src = readFileSync(path.join(cwd, rel), 'utf8');
|
|
909
|
+
}
|
|
910
|
+
catch {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
collectProducersFromSource(src, full);
|
|
914
|
+
collectProducersFromSource(src, production);
|
|
915
|
+
sources.push({ rel, src });
|
|
916
|
+
}
|
|
917
|
+
const refs = [];
|
|
918
|
+
for (const { rel, src } of sources)
|
|
919
|
+
refs.push(...extractGeneratedHtmlRefs(src, rel, production));
|
|
920
|
+
return { refs, full, production };
|
|
668
921
|
}
|
|
669
922
|
/** Ranked-failure text for the final gate (names referencer + missing path). */
|
|
670
923
|
export function danglingGateFailureText(d) {
|
|
@@ -83,12 +83,57 @@ export declare function discoverIntegrationCommands(cwd: string): {
|
|
|
83
83
|
};
|
|
84
84
|
/** Every lockfile consistency check that applies to this tree (possibly none). */
|
|
85
85
|
export declare function discoverLockfileChecks(cwd: string): HealthCommand[];
|
|
86
|
+
/**
|
|
87
|
+
* Why this script is NOT a launch of the shipped app, or null when it plausibly
|
|
88
|
+
* is one (mx5 run 18, validated).
|
|
89
|
+
*
|
|
90
|
+
* Run 18's boot command resolved to `bun run dev`, whose body is
|
|
91
|
+
* `docker compose -f docker-compose.dev.yml up -d && until docker compose … pg_isready
|
|
92
|
+
* … && concurrently "bun run dev:css" "bun run dev:js" "bun run --watch
|
|
93
|
+
* src/server/index.ts"`. The gate sandbox has no docker, so the chain died at 127 and
|
|
94
|
+
* the boot SKIPPED as an environment gap — while the shipped app had no HTTP listener
|
|
95
|
+
* at all. A script whose first act is `docker compose up` cannot distinguish "the app
|
|
96
|
+
* is broken" from "this box has no docker", so it is not evidence either way: better
|
|
97
|
+
* to discover NO boot command — reported as "nothing to boot" — and let the static
|
|
98
|
+
* serve-entry check (serve-entry.ts) carry the signal, than to spend the grace window
|
|
99
|
+
* producing an unfalsifiable skip.
|
|
100
|
+
*
|
|
101
|
+
* CONSERVATIVE AND LEXICAL BY CONSTRUCTION. Only two shapes are rejected, both
|
|
102
|
+
* decidable from the script text alone:
|
|
103
|
+
* 1. the chain OPENS with container orchestration (docker/podman/nerdctl … up|start|run);
|
|
104
|
+
* 2. the whole body is a multiplexer (concurrently/npm-run-all/run-p/run-s/turbo)
|
|
105
|
+
* whose every child is an ASSET watcher in watch mode (tailwind/tsc/esbuild/…),
|
|
106
|
+
* i.e. nothing in it can ever listen.
|
|
107
|
+
* Anything else — `vite`, `next dev`, `node dist/index.js`, `nodemon`, `bun --watch
|
|
108
|
+
* src/index.ts`, and any multiplexer with one non-asset child — is accepted
|
|
109
|
+
* unchanged. Deciding whether a watcher actually SERVES is not attempted here; that
|
|
110
|
+
* is exactly what the static serve-entry check is for.
|
|
111
|
+
*/
|
|
112
|
+
export declare function nonLaunchScriptReason(body: string, scripts?: Record<string, string>): string | null;
|
|
86
113
|
/**
|
|
87
114
|
* The project's OWN launch command, if it declares one (package.json `start`,
|
|
88
115
|
* else `dev`; Makefile `run`). null means the project has nothing to boot —
|
|
89
116
|
* the boot check degrades to nothing-to-run.
|
|
117
|
+
*
|
|
118
|
+
* A script that is not a LAUNCH at all (nonLaunchScriptReason — mx5 run 18's
|
|
119
|
+
* `docker compose up` orchestrator) is rejected here and falls through to the
|
|
120
|
+
* next candidate, then to null. Discovering nothing is strictly better than
|
|
121
|
+
* discovering something unfalsifiable: an env-gap skip of an orchestration script
|
|
122
|
+
* says nothing about the app, and null is reported as "nothing to boot".
|
|
90
123
|
*/
|
|
91
124
|
export declare function discoverBootCommand(cwd: string): HealthCommand | null;
|
|
125
|
+
/**
|
|
126
|
+
* The launch script that EXISTS but was rejected as not-a-launch, if any. Without
|
|
127
|
+
* this the rejection would trade run 18's unfalsifiable skip for pure silence: no
|
|
128
|
+
* boot command means bootSkipVerdict has no label to name, and a project whose test
|
|
129
|
+
* suite ran still reports `observed > 0`, so unobservedVerdict stays quiet too. A
|
|
130
|
+
* served app whose only declared launch script cannot start it was not observed to
|
|
131
|
+
* run, and must say so.
|
|
132
|
+
*/
|
|
133
|
+
export declare function rejectedLaunchScript(cwd: string): {
|
|
134
|
+
name: string;
|
|
135
|
+
reason: string;
|
|
136
|
+
} | null;
|
|
92
137
|
type BootOutcome = {
|
|
93
138
|
outcome: 'skip' | 'pass';
|
|
94
139
|
/** Set when the render check could not OBSERVE the served page (no browser,
|
package/dist/task/final-gate.js
CHANGED
|
@@ -58,6 +58,7 @@ import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-r
|
|
|
58
58
|
import { resolveRunner, runnerEnv } from './runner-resolve.js';
|
|
59
59
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
60
60
|
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
61
|
+
import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
|
|
61
62
|
function packageScripts(cwd) {
|
|
62
63
|
try {
|
|
63
64
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -192,16 +193,128 @@ export function discoverLockfileChecks(cwd) {
|
|
|
192
193
|
}
|
|
193
194
|
return cmds;
|
|
194
195
|
}
|
|
196
|
+
/** Leading `FOO=bar` env assignments and `sudo`/`exec` wrappers carry no verb. */
|
|
197
|
+
function commandTokens(member) {
|
|
198
|
+
const t = member.trim().split(/\s+/).filter(Boolean);
|
|
199
|
+
while (t.length > 0
|
|
200
|
+
&& (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t[0]) || /^(?:sudo|exec|env)$/.test(t[0]))) {
|
|
201
|
+
t.shift();
|
|
202
|
+
}
|
|
203
|
+
return t;
|
|
204
|
+
}
|
|
205
|
+
/** The chain members of a shell script body, in order (`&&`, `||`, `;`, `|`). */
|
|
206
|
+
function chainMembers(body) {
|
|
207
|
+
return body
|
|
208
|
+
.split(/&&|\|\||;|\|/)
|
|
209
|
+
.map(s => s.trim())
|
|
210
|
+
.filter(s => s.length > 0);
|
|
211
|
+
}
|
|
212
|
+
/** Container/infra orchestration: `docker compose … up`, `docker-compose … up -d`,
|
|
213
|
+
* `podman-compose … up`, `docker run …`. The verb must be a bare token, so a
|
|
214
|
+
* filename like `docker-compose.dev.yml` never counts as one. */
|
|
215
|
+
function isContainerOrchestration(member) {
|
|
216
|
+
const t = commandTokens(member);
|
|
217
|
+
if (t.length === 0)
|
|
218
|
+
return false;
|
|
219
|
+
const bin = path.posix.basename(t[0]);
|
|
220
|
+
if (!/^(?:docker|podman|nerdctl)(?:-compose)?$/.test(bin))
|
|
221
|
+
return false;
|
|
222
|
+
const verbs = new Set(['up', 'start', 'run']);
|
|
223
|
+
return t.slice(1).some(tok => verbs.has(tok));
|
|
224
|
+
}
|
|
225
|
+
const MULTIPLEXER_RE = /^(?:concurrently|npm-run-all|run-p|run-s|turbo)$/;
|
|
226
|
+
/** A watcher that recompiles ASSETS and never listens: the tool is a
|
|
227
|
+
* bundler/compiler/preprocessor AND it is in watch mode. `bun run --watch x.ts`
|
|
228
|
+
* is deliberately NOT here — that re-executes an entrypoint, which may serve. */
|
|
229
|
+
const ASSET_TOOL_RE = /(?:^|[\s/@])(?:tailwindcss|postcss|sass|node-sass|less|stylus|esbuild|rollup|webpack|parcel|swc|babel|tsc|tsup|chokidar)(?:$|[\s"'])/;
|
|
230
|
+
const WATCH_FLAG_RE = /(?:^|\s)(?:--watch|-w|--watch=[^\s]*)(?:\s|$)/;
|
|
231
|
+
/** The quoted commands a multiplexer runs, or its bare script-name arguments
|
|
232
|
+
* resolved through the manifest (`run-p dev:css dev:js`). One level only. */
|
|
233
|
+
function multiplexerChildren(member, scripts) {
|
|
234
|
+
const quoted = [...member.matchAll(/"([^"]+)"|'([^']+)'/g)].map(m => m[1] ?? m[2]);
|
|
235
|
+
if (quoted.length > 0)
|
|
236
|
+
return quoted;
|
|
237
|
+
const t = commandTokens(member)
|
|
238
|
+
.slice(1)
|
|
239
|
+
.filter(a => !a.startsWith('-'));
|
|
240
|
+
return t.flatMap(name => (scripts[name] !== undefined ? [scripts[name]] : []));
|
|
241
|
+
}
|
|
242
|
+
/** Every member of the chain that could plausibly stay up and serve. Members that
|
|
243
|
+
* are one-shot setup (`mkdir`, `sleep`, an `until … done` wait loop) are not
|
|
244
|
+
* themselves launches, but they are not disqualifying either — only the two
|
|
245
|
+
* shapes below are. */
|
|
246
|
+
function isWatcherOnlyMultiplexer(member, scripts) {
|
|
247
|
+
const t = commandTokens(member);
|
|
248
|
+
if (t.length === 0)
|
|
249
|
+
return false;
|
|
250
|
+
const bin = path.posix.basename(t[0]);
|
|
251
|
+
const runner = /^(?:npx|bunx|pnpm|yarn|npm)$/.test(bin);
|
|
252
|
+
const head = runner ?
|
|
253
|
+
(t.slice(1).find(a => !a.startsWith('-') && a !== 'exec' && a !== 'dlx' && a !== 'run')
|
|
254
|
+
?? '')
|
|
255
|
+
: bin;
|
|
256
|
+
if (!MULTIPLEXER_RE.test(path.posix.basename(head)))
|
|
257
|
+
return false;
|
|
258
|
+
const children = multiplexerChildren(member, scripts);
|
|
259
|
+
if (children.length === 0)
|
|
260
|
+
return false;
|
|
261
|
+
// Every child is an ASSET watcher ⇒ nothing in here ever listens.
|
|
262
|
+
return children.every(c => ASSET_TOOL_RE.test(c) && WATCH_FLAG_RE.test(c));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Why this script is NOT a launch of the shipped app, or null when it plausibly
|
|
266
|
+
* is one (mx5 run 18, validated).
|
|
267
|
+
*
|
|
268
|
+
* Run 18's boot command resolved to `bun run dev`, whose body is
|
|
269
|
+
* `docker compose -f docker-compose.dev.yml up -d && until docker compose … pg_isready
|
|
270
|
+
* … && concurrently "bun run dev:css" "bun run dev:js" "bun run --watch
|
|
271
|
+
* src/server/index.ts"`. The gate sandbox has no docker, so the chain died at 127 and
|
|
272
|
+
* the boot SKIPPED as an environment gap — while the shipped app had no HTTP listener
|
|
273
|
+
* at all. A script whose first act is `docker compose up` cannot distinguish "the app
|
|
274
|
+
* is broken" from "this box has no docker", so it is not evidence either way: better
|
|
275
|
+
* to discover NO boot command — reported as "nothing to boot" — and let the static
|
|
276
|
+
* serve-entry check (serve-entry.ts) carry the signal, than to spend the grace window
|
|
277
|
+
* producing an unfalsifiable skip.
|
|
278
|
+
*
|
|
279
|
+
* CONSERVATIVE AND LEXICAL BY CONSTRUCTION. Only two shapes are rejected, both
|
|
280
|
+
* decidable from the script text alone:
|
|
281
|
+
* 1. the chain OPENS with container orchestration (docker/podman/nerdctl … up|start|run);
|
|
282
|
+
* 2. the whole body is a multiplexer (concurrently/npm-run-all/run-p/run-s/turbo)
|
|
283
|
+
* whose every child is an ASSET watcher in watch mode (tailwind/tsc/esbuild/…),
|
|
284
|
+
* i.e. nothing in it can ever listen.
|
|
285
|
+
* Anything else — `vite`, `next dev`, `node dist/index.js`, `nodemon`, `bun --watch
|
|
286
|
+
* src/index.ts`, and any multiplexer with one non-asset child — is accepted
|
|
287
|
+
* unchanged. Deciding whether a watcher actually SERVES is not attempted here; that
|
|
288
|
+
* is exactly what the static serve-entry check is for.
|
|
289
|
+
*/
|
|
290
|
+
export function nonLaunchScriptReason(body, scripts = {}) {
|
|
291
|
+
const members = chainMembers(body);
|
|
292
|
+
if (members.length === 0)
|
|
293
|
+
return null;
|
|
294
|
+
if (isContainerOrchestration(members[0])) {
|
|
295
|
+
return 'it opens with container orchestration, which starts infrastructure rather than the app';
|
|
296
|
+
}
|
|
297
|
+
if (members.every(m => isWatcherOnlyMultiplexer(m, scripts))) {
|
|
298
|
+
return 'its only long-running member multiplexes asset watchers, none of which serves';
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
195
302
|
/**
|
|
196
303
|
* The project's OWN launch command, if it declares one (package.json `start`,
|
|
197
304
|
* else `dev`; Makefile `run`). null means the project has nothing to boot —
|
|
198
305
|
* the boot check degrades to nothing-to-run.
|
|
306
|
+
*
|
|
307
|
+
* A script that is not a LAUNCH at all (nonLaunchScriptReason — mx5 run 18's
|
|
308
|
+
* `docker compose up` orchestrator) is rejected here and falls through to the
|
|
309
|
+
* next candidate, then to null. Discovering nothing is strictly better than
|
|
310
|
+
* discovering something unfalsifiable: an env-gap skip of an orchestration script
|
|
311
|
+
* says nothing about the app, and null is reported as "nothing to boot".
|
|
199
312
|
*/
|
|
200
313
|
export function discoverBootCommand(cwd) {
|
|
201
314
|
if (existsSync(path.join(cwd, 'package.json'))) {
|
|
202
315
|
const s = packageScripts(cwd);
|
|
203
316
|
for (const name of ['start', 'dev']) {
|
|
204
|
-
if (s[name])
|
|
317
|
+
if (s[name] && nonLaunchScriptReason(s[name], s) === null)
|
|
205
318
|
return ['bun', ['run', name]];
|
|
206
319
|
}
|
|
207
320
|
return null;
|
|
@@ -211,6 +324,28 @@ export function discoverBootCommand(cwd) {
|
|
|
211
324
|
}
|
|
212
325
|
return null;
|
|
213
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* The launch script that EXISTS but was rejected as not-a-launch, if any. Without
|
|
329
|
+
* this the rejection would trade run 18's unfalsifiable skip for pure silence: no
|
|
330
|
+
* boot command means bootSkipVerdict has no label to name, and a project whose test
|
|
331
|
+
* suite ran still reports `observed > 0`, so unobservedVerdict stays quiet too. A
|
|
332
|
+
* served app whose only declared launch script cannot start it was not observed to
|
|
333
|
+
* run, and must say so.
|
|
334
|
+
*/
|
|
335
|
+
export function rejectedLaunchScript(cwd) {
|
|
336
|
+
if (!existsSync(path.join(cwd, 'package.json')))
|
|
337
|
+
return null;
|
|
338
|
+
const s = packageScripts(cwd);
|
|
339
|
+
for (const name of ['start', 'dev']) {
|
|
340
|
+
if (!s[name])
|
|
341
|
+
continue;
|
|
342
|
+
const reason = nonLaunchScriptReason(s[name], s);
|
|
343
|
+
if (reason === null)
|
|
344
|
+
return null; // this one IS a launch — it was chosen
|
|
345
|
+
return { name, reason };
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
214
349
|
/** Recognise an "address already in use" bind failure across runtimes (Node
|
|
215
350
|
* EADDRINUSE, Bun "Is port N in use?", Go "address already in use", generic). */
|
|
216
351
|
function isAddressInUse(text) {
|
|
@@ -1058,6 +1193,23 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1058
1193
|
fail(`launch contract: the design declares script(s) the shipped package.json does not expose: ${missing.join(', ')} (declared: ${declared.join(', ')})`);
|
|
1059
1194
|
}
|
|
1060
1195
|
}
|
|
1196
|
+
// Serve-entry closure (mx5 run 18, nexttask 2B): the tree builds a server app,
|
|
1197
|
+
// expects to serve (SPA fallback / static read / a design clause), and NOTHING
|
|
1198
|
+
// anywhere starts a listener — `src/server/index.ts` ended at `export {app}`, so
|
|
1199
|
+
// the product could not be started at all while every dynamic probe went blind on
|
|
1200
|
+
// a docker-less box. Static, deterministic, milliseconds, and — unlike the boot
|
|
1201
|
+
// check — decidable in exactly the environment where the boot skipped. Rank 0:
|
|
1202
|
+
// "the app cannot be started" is the same load-bearing class as boot/render.
|
|
1203
|
+
// Placed BEFORE the zero-discovery early return on purpose: a project with no
|
|
1204
|
+
// runnable command at all must still fail this, not report UNOBSERVED.
|
|
1205
|
+
try {
|
|
1206
|
+
const noServeEntry = findMissingServeEntry(cwd, planText);
|
|
1207
|
+
if (noServeEntry)
|
|
1208
|
+
fail(serveEntryGateFailureText(noServeEntry), 0);
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
// best-effort scan — a scanner fault must never break the gate
|
|
1212
|
+
}
|
|
1061
1213
|
const lockCmds = discoverLockfileChecks(cwd);
|
|
1062
1214
|
const { cmds } = discoverIntegrationCommands(cwd);
|
|
1063
1215
|
const boot = discoverBootCommand(cwd);
|
|
@@ -1225,6 +1377,18 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1225
1377
|
warnings.push(b.renderNote);
|
|
1226
1378
|
}
|
|
1227
1379
|
}
|
|
1380
|
+
else {
|
|
1381
|
+
// Nothing to boot — but if the reason is that the project's only launch
|
|
1382
|
+
// script was REJECTED as not-a-launch (2A), that is not the same thing as a
|
|
1383
|
+
// project with no launch surface, and it must not degrade into silence.
|
|
1384
|
+
const rejected = rejectedLaunchScript(cwd);
|
|
1385
|
+
if (rejected && detectsServedApp(cwd, planText)) {
|
|
1386
|
+
bootUnobserved =
|
|
1387
|
+
`boot check: this project's only launch script (\`${rejected.name}\`) is not a `
|
|
1388
|
+
+ `launch — ${rejected.reason} — so nothing was started and the app was never `
|
|
1389
|
+
+ 'observed to run.';
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1228
1392
|
// Full-skip blindness guard (mx5 run 16): commands were discovered but every
|
|
1229
1393
|
// one skipped → rank-0 failure, never a static-only PASS. Runner resolvability
|
|
1230
1394
|
// is checked through resolveRunner so the failure text can name the missing
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface ServeEntryFinding {
|
|
2
|
+
/** Repo-relative module that constructs the app and is the natural home for the bind. */
|
|
3
|
+
file: string;
|
|
4
|
+
/** The construct that matched there (`new Hono()`, `express()`, …). */
|
|
5
|
+
construct: string;
|
|
6
|
+
/** Why this project is expected to serve, in words. */
|
|
7
|
+
expectation: string;
|
|
8
|
+
/** Where that expectation was found (repo-relative file, or 'the design/spec'). */
|
|
9
|
+
expectationSource: string;
|
|
10
|
+
/** Every construction file found, for the report. */
|
|
11
|
+
appFiles: string[];
|
|
12
|
+
}
|
|
13
|
+
/** Constructions in one source file, plus the variable each was assigned to. */
|
|
14
|
+
export declare function findAppConstructions(raw: string): Array<{
|
|
15
|
+
construct: string;
|
|
16
|
+
name: string | null;
|
|
17
|
+
}>;
|
|
18
|
+
/** Bind evidence in one source file, or null. */
|
|
19
|
+
export declare function findBindEvidence(src: string, appNames?: Set<string>): string | null;
|
|
20
|
+
/** Why this file makes the project a SERVING one, or null. */
|
|
21
|
+
export declare function findServeExpectation(src: string): string | null;
|
|
22
|
+
/** A design/spec clause that names a served path — the plan-side half of the same
|
|
23
|
+
* expectation (mx5's `DESIGN/PROJECT.md:285`: "serves `/api` + static `dist/`"). */
|
|
24
|
+
export declare function planExpectsServing(planText: string | undefined): string | null;
|
|
25
|
+
/** The platform that would bind on this project's behalf, or null. */
|
|
26
|
+
export declare function opaqueLauncher(cwd: string): string | null;
|
|
27
|
+
/** What a whole tree looks like to this check — the base-rate row, and the
|
|
28
|
+
* intermediate the finding is derived from. */
|
|
29
|
+
export interface ServeEntryScan {
|
|
30
|
+
/** Files that construct a server app, with the construct that matched. */
|
|
31
|
+
apps: Array<{
|
|
32
|
+
file: string;
|
|
33
|
+
construct: string;
|
|
34
|
+
name: string | null;
|
|
35
|
+
}>;
|
|
36
|
+
/** The first bind found anywhere, with its file. */
|
|
37
|
+
bind: {
|
|
38
|
+
file: string;
|
|
39
|
+
what: string;
|
|
40
|
+
} | null;
|
|
41
|
+
/** Why the project is expected to serve, with its source. */
|
|
42
|
+
expectation: {
|
|
43
|
+
source: string;
|
|
44
|
+
what: string;
|
|
45
|
+
} | null;
|
|
46
|
+
/** Non-null ⇒ somebody else's launcher owns the listener; the check stands down. */
|
|
47
|
+
launcher: string | null;
|
|
48
|
+
filesScanned: number;
|
|
49
|
+
}
|
|
50
|
+
/** Read the tree once and answer all three questions. Read-only, deterministic. */
|
|
51
|
+
export declare function scanServeEntry(cwd: string, planText?: string): ServeEntryScan;
|
|
52
|
+
/**
|
|
53
|
+
* FINAL-GATE seam: does this project build a server app it expects to serve, with
|
|
54
|
+
* nothing anywhere to start it? Returns at most ONE finding — the defect is a
|
|
55
|
+
* property of the whole tree, not of each file. Best-effort and read-only.
|
|
56
|
+
*/
|
|
57
|
+
export declare function findMissingServeEntry(cwd: string, planText?: string): ServeEntryFinding | null;
|
|
58
|
+
/** Ranked-failure text for the final gate. Names the module that must bind and the
|
|
59
|
+
* reason the project is a serving one, so the autofix child has both halves. */
|
|
60
|
+
export declare function serveEntryGateFailureText(f: ServeEntryFinding): string;
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serve-entry — the project builds a server app, expects to SERVE, and nothing in
|
|
3
|
+
* the tree ever starts a listener (nexttask 2 part B).
|
|
4
|
+
*
|
|
5
|
+
* THE FAILURE THIS CLOSES (mx5 run 18, measured). The shipped `src/server/index.ts`
|
|
6
|
+
* is 28 lines that construct a Hono app, mount five `/api` routers, add an SPA
|
|
7
|
+
* fallback reading `Bun.file('dist/index.html')` — and end at `export {app}`. There
|
|
8
|
+
* is no `Bun.serve`, no `export default app`, no `serve()` from an adapter, no
|
|
9
|
+
* `start` script. Running the entry directly exits 0 in milliseconds:
|
|
10
|
+
*
|
|
11
|
+
* $ DATABASE_URL=… timeout 15 bun run src/server/index.ts → EXIT=0
|
|
12
|
+
*
|
|
13
|
+
* The product could not be started at all, and the run shipped green: the gate's
|
|
14
|
+
* boot command resolved to a `docker compose up` orchestrator, docker was absent in
|
|
15
|
+
* the sandbox, and the boot SKIPPED. This is the SECOND run to lose this exact
|
|
16
|
+
* clause — mx5 run 16 shipped a server that never served the client bundle
|
|
17
|
+
* (scripts/live-owned-requirement-compose-ab.ts) — so a dynamic-only gate has now
|
|
18
|
+
* failed to catch it twice.
|
|
19
|
+
*
|
|
20
|
+
* WHY STATIC. The check needs no runtime, no browser, no docker, no database and no
|
|
21
|
+
* model: the tree either contains a bind or it does not. It runs in milliseconds and
|
|
22
|
+
* is decidable in exactly the environment where every dynamic probe went blind.
|
|
23
|
+
*
|
|
24
|
+
* FP DISCIPLINE (the standing rule — inconclusive is NEVER evidence). Three
|
|
25
|
+
* independent conditions must ALL hold before anything is reported, and each one
|
|
26
|
+
* steps aside on doubt:
|
|
27
|
+
* 1. a module CONSTRUCTS a server app (a known framework construct, literal);
|
|
28
|
+
* 2. the project is expected to SERVE (a catch-all route, a static/dist read, a
|
|
29
|
+
* static-file middleware, or a design clause naming a served path);
|
|
30
|
+
* 3. NOTHING anywhere in the scanned tree binds — `Bun.serve(`/`Deno.serve(`,
|
|
31
|
+
* any `.listen(`, an adapter `serve(` import, `export default <the app>` or
|
|
32
|
+
* `export default {fetch…}` (the Workers/Bun default-export protocol),
|
|
33
|
+
* `app.fire()`, or a platform handler export.
|
|
34
|
+
* And a project whose LAUNCHER is somebody else's (next/nuxt/astro/remix/sveltekit/
|
|
35
|
+
* nest/wrangler/vercel/netlify/serverless/…, by dependency or by script) steps aside
|
|
36
|
+
* whole: those frameworks bind inside their own CLI, so their app modules correctly
|
|
37
|
+
* contain no listener and the question is not decidable from this tree.
|
|
38
|
+
*
|
|
39
|
+
* Ground truth is the file tree only. No model, no network.
|
|
40
|
+
*/
|
|
41
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
/** A server-app construction: framework, and the regex that recognises it. */
|
|
44
|
+
const CONSTRUCT_PATTERNS = [
|
|
45
|
+
{ re: /\bnew\s+Hono\s*[<(]/, construct: 'new Hono()' },
|
|
46
|
+
{ re: /\bnew\s+Elysia\s*[<(]/, construct: 'new Elysia()' },
|
|
47
|
+
{ re: /\bnew\s+Koa\s*\(/, construct: 'new Koa()' },
|
|
48
|
+
{ re: /\bnew\s+Application\s*\(\s*\)/, construct: 'new Application()' },
|
|
49
|
+
{ re: /(?:^|[^.\w])express\s*\(\s*\)/, construct: 'express()' },
|
|
50
|
+
{ re: /(?:^|[^.\w])[Ff]astify\s*\(/, construct: 'fastify()' },
|
|
51
|
+
{ re: /(?:^|[^.\w])polka\s*\(/, construct: 'polka()' }
|
|
52
|
+
// `connect()` (the middleware framework) is deliberately absent: gofer's
|
|
53
|
+
// src/store/db.ts calls `connect()` on a DATABASE, and a construct signal that
|
|
54
|
+
// cannot tell a server from a db handle is not a construct signal.
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Anything that starts (or hands off) a listener. Deliberately GENEROUS: every
|
|
58
|
+
* pattern here SUPPRESSES a finding, so a loose match costs a missed defect while a
|
|
59
|
+
* tight one costs a false accusation — and the standing direction is that a false
|
|
60
|
+
* accusation is the worse failure.
|
|
61
|
+
*/
|
|
62
|
+
const BIND_PATTERNS = [
|
|
63
|
+
{ re: /\bBun\s*\.\s*serve\s*\(/, what: 'Bun.serve(' },
|
|
64
|
+
{ re: /\bDeno\s*\.\s*serve\s*\(/, what: 'Deno.serve(' },
|
|
65
|
+
{ re: /\.listen\s*\(/, what: '.listen(' },
|
|
66
|
+
{ re: /\bcreateServer\s*\([^)]*\)\s*\.\s*listen/, what: 'createServer().listen(' },
|
|
67
|
+
{ re: /\.\s*fire\s*\(\s*\)/, what: 'app.fire()' },
|
|
68
|
+
{ re: /\bserveHandler\s*\(|\bstartServer\s*\(/, what: 'a server-start helper' }
|
|
69
|
+
];
|
|
70
|
+
/** `export default` forms that ARE a bind: the runtime (Bun, Workers, Deno Deploy,
|
|
71
|
+
* Vercel) starts whatever is exported. A default-exported config object
|
|
72
|
+
* (`export default defineConfig({…})`) is not one of them, and neither is a React
|
|
73
|
+
* component — hence the identifier must be a name the tree BOUND to a server-app
|
|
74
|
+
* construction (aiz-client's `export default App` is a component, not a listener). */
|
|
75
|
+
function defaultExportBind(src, appNames) {
|
|
76
|
+
for (const m of src.matchAll(/export\s+default\s+([A-Za-z_$][\w$]*)/g)) {
|
|
77
|
+
if (appNames.has(m[1]))
|
|
78
|
+
return `export default ${m[1]}`;
|
|
79
|
+
}
|
|
80
|
+
// `export default {fetch: app.fetch, port}` / `export default {async fetch(…)}` —
|
|
81
|
+
// the Workers/Bun protocol. The `fetch` key must be in the same object literal.
|
|
82
|
+
for (const m of src.matchAll(/export\s+default\s*\{([\s\S]{0,400}?)\}/g)) {
|
|
83
|
+
if (/(?:^|[\s,{])(?:async\s+)?fetch\s*[:(]/.test(m[1]))
|
|
84
|
+
return 'export default {fetch…}';
|
|
85
|
+
}
|
|
86
|
+
if (/export\s+default\s+(?:handle|serve|createHandler)\s*\(/.test(src)) {
|
|
87
|
+
return 'export default handle(app)';
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
/** An adapter whose `serve(app)` binds for you (`@hono/node-server`, `srvx`, …). */
|
|
92
|
+
function adapterServeBind(src) {
|
|
93
|
+
const importsAdapter = /from\s+['"](?:@hono\/node-server|srvx|@fastify\/[\w-]+|h3|listhen)['"]/.test(src);
|
|
94
|
+
return importsAdapter && /(?:^|[^.\w])(?:serve|listen)\s*\(/.test(src) ?
|
|
95
|
+
'serve() from a server adapter'
|
|
96
|
+
: null;
|
|
97
|
+
}
|
|
98
|
+
/** Blank out same-line string literals. Real code never constructs an app inside a
|
|
99
|
+
* quote, but a module that NAMES the constructs — a detector, a doc, this file's
|
|
100
|
+
* own `construct: 'new Hono()'` labels — otherwise reads as seven server apps.
|
|
101
|
+
* Applied to construction matching only: the serve-expectation patterns are ABOUT
|
|
102
|
+
* string literals (`app.get('*')`, `Bun.file('dist/…')`) and must keep them. */
|
|
103
|
+
function stripStringLiterals(src) {
|
|
104
|
+
return src.replace(/'[^'\n]*'|"[^"\n]*"/g, "''");
|
|
105
|
+
}
|
|
106
|
+
/** Constructions in one source file, plus the variable each was assigned to. */
|
|
107
|
+
export function findAppConstructions(raw) {
|
|
108
|
+
const src = stripStringLiterals(raw);
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const { re, construct } of CONSTRUCT_PATTERNS) {
|
|
111
|
+
if (!re.test(src))
|
|
112
|
+
continue;
|
|
113
|
+
// `const app = new Hono()` → remember `app`, so `export default app` counts.
|
|
114
|
+
const assign = new RegExp(`(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=]{0,80})?=\\s*[^=]{0,40}?${re.source}`).exec(src);
|
|
115
|
+
out.push({ construct, name: assign ? assign[1] : null });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
/** Bind evidence in one source file, or null. */
|
|
120
|
+
export function findBindEvidence(src, appNames = new Set()) {
|
|
121
|
+
for (const { re, what } of BIND_PATTERNS) {
|
|
122
|
+
if (re.test(src))
|
|
123
|
+
return what;
|
|
124
|
+
}
|
|
125
|
+
return defaultExportBind(src, appNames) ?? adapterServeBind(src);
|
|
126
|
+
}
|
|
127
|
+
const SERVE_EXPECTATIONS = [
|
|
128
|
+
{
|
|
129
|
+
re: /\.\s*(?:get|all|use|route)\s*\(\s*['"`]\/?\*['"`]/,
|
|
130
|
+
what: 'a catch-all route (the SPA fallback)'
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
re: /(?:Bun\s*\.\s*file|readFileSync|readFile|createReadStream|sendFile)\s*\(\s*['"`]\.?\/?(?:dist|build|out|public|static|client|assets)\//,
|
|
134
|
+
what: 'a read of a built asset under dist/ (the client bundle)'
|
|
135
|
+
},
|
|
136
|
+
{ re: /\bserveStatic\s*\(/, what: 'static-file middleware (serveStatic)' },
|
|
137
|
+
{ re: /\bexpress\s*\.\s*static\s*\(/, what: 'static-file middleware (express.static)' },
|
|
138
|
+
{ re: /\bfastifyStatic\b|@fastify\/static/, what: 'static-file middleware (@fastify/static)' }
|
|
139
|
+
];
|
|
140
|
+
/** Why this file makes the project a SERVING one, or null. */
|
|
141
|
+
export function findServeExpectation(src) {
|
|
142
|
+
for (const { re, what } of SERVE_EXPECTATIONS) {
|
|
143
|
+
if (re.test(src))
|
|
144
|
+
return what;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** A design/spec clause that names a served path — the plan-side half of the same
|
|
149
|
+
* expectation (mx5's `DESIGN/PROJECT.md:285`: "serves `/api` + static `dist/`"). */
|
|
150
|
+
export function planExpectsServing(planText) {
|
|
151
|
+
if (!planText)
|
|
152
|
+
return null;
|
|
153
|
+
const re = /\bserves?\b[^\n]{0,120}?\b(?:static|dist\/?|client|bundle|index\.html|spa|frontend)\b/i;
|
|
154
|
+
const m = re.exec(planText);
|
|
155
|
+
return m ? `the design declares a served path ("${m[0].trim().slice(0, 100)}")` : null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Frameworks and platforms that own the listener themselves. When one of these is a
|
|
159
|
+
* dependency or drives a script, an app module with no bind is CORRECT, so the whole
|
|
160
|
+
* check steps aside — it is not decidable from this tree.
|
|
161
|
+
*/
|
|
162
|
+
const LAUNCHER_DEPS = /^(?:next|nuxt|nuxt3|astro|@remix-run\/|@sveltejs\/kit|@nestjs\/core|@angular\/|@adonisjs\/core|redwoodjs|blitz|wrangler|@cloudflare\/|vercel|@vercel\/|netlify-cli|@netlify\/|serverless|serverless-http|firebase-functions|aws-lambda|@aws-sdk\/client-lambda|sst|nitropack|encore\.dev|@medusajs\/|keystone|payload|gatsby|@builder\.io\/qwik-city)/;
|
|
163
|
+
const LAUNCHER_SCRIPT_RE = /\b(?:next|nuxt|astro|remix-serve|nest|wrangler|vercel|netlify|sst|gatsby|blitz|redwood|encore|payload|medusa)\s+(?:dev|start|serve|build\s+&&|run\b)/;
|
|
164
|
+
/** The platform that would bind on this project's behalf, or null. */
|
|
165
|
+
export function opaqueLauncher(cwd) {
|
|
166
|
+
let pkg;
|
|
167
|
+
try {
|
|
168
|
+
pkg = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const deps = Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) });
|
|
174
|
+
const dep = deps.find(d => LAUNCHER_DEPS.test(d));
|
|
175
|
+
if (dep)
|
|
176
|
+
return `the \`${dep}\` framework starts its own server`;
|
|
177
|
+
for (const [name, body] of Object.entries(pkg.scripts ?? {})) {
|
|
178
|
+
if (LAUNCHER_SCRIPT_RE.test(body))
|
|
179
|
+
return `\`${name}\` runs a framework launcher (${body.slice(0, 60)})`;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
// Directories never scanned: VCS/dep trees, build output (bundled copies of the
|
|
184
|
+
// same sources), and test/fixture/example/doc trees — a test that stands up a
|
|
185
|
+
// throwaway listener is not the app's launch, and a doc snippet is not code.
|
|
186
|
+
const SKIP_DIR_RE = /^(?:\.git|node_modules|\.pi-tasks|dist|build|out|coverage|target|vendor|__pycache__|\.venv|venv|tmp|test|tests|__tests__|__mocks__|__fixtures__|fixtures|e2e|examples|example|docs|doc|bench|benchmarks)$/;
|
|
187
|
+
const SKIP_FILE_RE = /\.(?:test|spec|stories|bench)\.[a-z]+$|\.d\.[mc]?ts$/i;
|
|
188
|
+
const SCAN_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
|
|
189
|
+
const MAX_SCAN_FILES = 3000;
|
|
190
|
+
const MAX_FILE_BYTES = 400_000;
|
|
191
|
+
/** Authored sources, bounded and in deterministic order. */
|
|
192
|
+
function scanCandidates(cwd) {
|
|
193
|
+
const out = [];
|
|
194
|
+
const walk = (rel) => {
|
|
195
|
+
if (out.length >= MAX_SCAN_FILES)
|
|
196
|
+
return;
|
|
197
|
+
let entries;
|
|
198
|
+
try {
|
|
199
|
+
entries = readdirSync(path.join(cwd, rel)).sort();
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
for (const name of entries) {
|
|
205
|
+
if (out.length >= MAX_SCAN_FILES)
|
|
206
|
+
return;
|
|
207
|
+
const relPath = rel === '' ? name : `${rel}/${name}`;
|
|
208
|
+
let st;
|
|
209
|
+
try {
|
|
210
|
+
st = statSync(path.join(cwd, relPath));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (st.isDirectory()) {
|
|
216
|
+
if (name.startsWith('.') || SKIP_DIR_RE.test(name))
|
|
217
|
+
continue;
|
|
218
|
+
walk(relPath);
|
|
219
|
+
}
|
|
220
|
+
else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
|
|
221
|
+
if (SKIP_FILE_RE.test(name))
|
|
222
|
+
continue;
|
|
223
|
+
if (SCAN_RE.test(name))
|
|
224
|
+
out.push(relPath);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
walk('');
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
/** Strip comment-only lines — a `Bun.serve` quoted in a comment is not a bind, and
|
|
232
|
+
* a commented-out catch-all is not a route. Inline comments are left alone. */
|
|
233
|
+
function stripCommentLines(src) {
|
|
234
|
+
return src
|
|
235
|
+
.split('\n')
|
|
236
|
+
.filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
|
|
237
|
+
.join('\n');
|
|
238
|
+
}
|
|
239
|
+
/** A `/…/flags` literal whose body carries a regex METACHARACTER — `[`, `\`, `+`,
|
|
240
|
+
* `?`, `|`, a group. A path string like `'/api/admin'` has none, so it survives. */
|
|
241
|
+
const REGEX_LITERAL_RE = /\/(?![*/])((?:\\.|\[(?:\\.|[^\]\\])*\]|[^/\\\n])+)\/[gimsuyd]*/g;
|
|
242
|
+
const METACHAR_RE = /[\\[\]+*?^$|(){}]/;
|
|
243
|
+
/**
|
|
244
|
+
* Blank out regex literals. A project that MATCHES on `new Hono` — a linter, a
|
|
245
|
+
* codemod, this very module — does not construct one, and a source scanner that
|
|
246
|
+
* cannot tell the two apart reports every detector as an app (pi-task scanned
|
|
247
|
+
* itself and found "9 server apps", all of them pattern tables). Only literals
|
|
248
|
+
* carrying a metacharacter are stripped, so `'/api/v1'` is untouched, and because
|
|
249
|
+
* stripping can only REMOVE matches it is safe in the FP direction by construction.
|
|
250
|
+
*/
|
|
251
|
+
function stripRegexLiterals(src) {
|
|
252
|
+
return src.replace(REGEX_LITERAL_RE, (whole, body) => METACHAR_RE.test(body) ? ' ' : whole);
|
|
253
|
+
}
|
|
254
|
+
/** Read the tree once and answer all three questions. Read-only, deterministic. */
|
|
255
|
+
export function scanServeEntry(cwd, planText) {
|
|
256
|
+
const scan = {
|
|
257
|
+
apps: [],
|
|
258
|
+
bind: null,
|
|
259
|
+
expectation: null,
|
|
260
|
+
launcher: opaqueLauncher(cwd),
|
|
261
|
+
filesScanned: 0
|
|
262
|
+
};
|
|
263
|
+
const files = scanCandidates(cwd);
|
|
264
|
+
scan.filesScanned = files.length;
|
|
265
|
+
// Two passes: constructions first, so `export default app` can be resolved
|
|
266
|
+
// against the app variable names the tree actually uses.
|
|
267
|
+
const sources = new Map();
|
|
268
|
+
const appNames = new Set();
|
|
269
|
+
for (const rel of files) {
|
|
270
|
+
let src;
|
|
271
|
+
try {
|
|
272
|
+
src = stripRegexLiterals(stripCommentLines(readFileSync(path.join(cwd, rel), 'utf8')));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
sources.set(rel, src);
|
|
278
|
+
for (const c of findAppConstructions(src)) {
|
|
279
|
+
scan.apps.push({ file: rel, construct: c.construct, name: c.name });
|
|
280
|
+
if (c.name)
|
|
281
|
+
appNames.add(c.name);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const [rel, src] of sources) {
|
|
285
|
+
if (scan.bind === null) {
|
|
286
|
+
const what = findBindEvidence(src, appNames);
|
|
287
|
+
if (what)
|
|
288
|
+
scan.bind = { file: rel, what };
|
|
289
|
+
}
|
|
290
|
+
if (scan.expectation === null) {
|
|
291
|
+
const what = findServeExpectation(src);
|
|
292
|
+
if (what)
|
|
293
|
+
scan.expectation = { source: rel, what };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (scan.expectation === null) {
|
|
297
|
+
const fromPlan = planExpectsServing(planText);
|
|
298
|
+
if (fromPlan)
|
|
299
|
+
scan.expectation = { source: 'the design/spec', what: fromPlan };
|
|
300
|
+
}
|
|
301
|
+
return scan;
|
|
302
|
+
}
|
|
303
|
+
/** The construction file that should carry the bind: the one holding the serve
|
|
304
|
+
* expectation, else the most entry-like name, else the first. */
|
|
305
|
+
function entryFile(scan) {
|
|
306
|
+
const withExpectation = scan.apps.find(a => a.file === scan.expectation?.source);
|
|
307
|
+
if (withExpectation)
|
|
308
|
+
return withExpectation;
|
|
309
|
+
const entryish = scan.apps.find(a => /(?:^|\/)(?:index|main|server|app)\.[a-z]+$/i.test(a.file));
|
|
310
|
+
return entryish ?? scan.apps[0];
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* FINAL-GATE seam: does this project build a server app it expects to serve, with
|
|
314
|
+
* nothing anywhere to start it? Returns at most ONE finding — the defect is a
|
|
315
|
+
* property of the whole tree, not of each file. Best-effort and read-only.
|
|
316
|
+
*/
|
|
317
|
+
export function findMissingServeEntry(cwd, planText) {
|
|
318
|
+
const scan = scanServeEntry(cwd, planText);
|
|
319
|
+
if (scan.launcher !== null)
|
|
320
|
+
return null;
|
|
321
|
+
if (scan.apps.length === 0)
|
|
322
|
+
return null;
|
|
323
|
+
if (scan.bind !== null)
|
|
324
|
+
return null;
|
|
325
|
+
if (scan.expectation === null)
|
|
326
|
+
return null;
|
|
327
|
+
const { file, construct } = entryFile(scan);
|
|
328
|
+
return {
|
|
329
|
+
file,
|
|
330
|
+
construct,
|
|
331
|
+
expectation: scan.expectation.what,
|
|
332
|
+
expectationSource: scan.expectation.source,
|
|
333
|
+
appFiles: [...new Set(scan.apps.map(a => a.file))]
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/** Ranked-failure text for the final gate. Names the module that must bind and the
|
|
337
|
+
* reason the project is a serving one, so the autofix child has both halves. */
|
|
338
|
+
export function serveEntryGateFailureText(f) {
|
|
339
|
+
return (`serve entry missing: \`${f.file}\` builds a server app (${f.construct}) and the project `
|
|
340
|
+
+ `is expected to serve — ${f.expectation} in ${f.expectationSource === 'the design/spec' ? 'the design/spec' : `\`${f.expectationSource}\``} — `
|
|
341
|
+
+ 'but NOTHING in the tree ever starts a listener (no `Bun.serve(`, `export default app`, '
|
|
342
|
+
+ 'adapter `serve()`, or `.listen(`), so the app cannot be started at all');
|
|
343
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Deterministic task planning and spec-orchestration for local models — crash-safe /task pipelines with verify/enforce gates, a real-time remote web view, and web/docs/fetch/worker subagent tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|