@mjasnikovs/pi-task 0.25.0 → 0.27.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/accept-debt.d.ts +15 -1
- package/dist/task/accept-debt.js +18 -0
- package/dist/task/artifact-closure.d.ts +71 -1
- package/dist/task/artifact-closure.js +274 -21
- package/dist/task/enforce-attribution.d.ts +128 -0
- package/dist/task/enforce-attribution.js +213 -0
- package/dist/task/final-gate.d.ts +3 -1
- package/dist/task/final-gate.js +12 -4
- package/dist/task/gate-deps.js +34 -2
- package/dist/task/repo-health-check.js +7 -5
- package/dist/task/runner-resolve.d.ts +25 -0
- package/dist/task/runner-resolve.js +31 -0
- package/dist/task/task-gates.d.ts +22 -5
- package/dist/task/task-gates.js +73 -6
- package/package.json +1 -1
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
* server entry point … the Hono server cannot be started"), so the terminal defect
|
|
7
7
|
* was FOUND and then erased by the very mechanism that found it. Persisted here so
|
|
8
8
|
* the final gate re-checks and surfaces it instead of letting it die with the revert.
|
|
9
|
+
* - 'enforce-kept' — the same enforce re-verify FAIL, but the failing check named
|
|
10
|
+
* only files the ENFORCE COMMIT does not touch, so reverting that commit could not
|
|
11
|
+
* possibly repair it (mx5 run 18 TASK_0024: a one-line paren removal in `Admin.tsx`
|
|
12
|
+
* was reverted over a `MyListings.spec.tsx` CT failure it cannot reach, and the
|
|
13
|
+
* final gate re-made the identical change 5 minutes later). The edits are KEPT and
|
|
14
|
+
* the defect is recorded here — keeping the work must never mean losing the finding.
|
|
9
15
|
* - 'frozen-blocked' — a repo-health verify-FAIL whose only fix is an edit to a path
|
|
10
16
|
* THIS task's spec froze (mx5 run 12: `bun run lint` permanently red because the
|
|
11
17
|
* created files need a tsconfig registration every spec forbids). Cross-task
|
|
@@ -43,7 +49,7 @@
|
|
|
43
49
|
* (root-cause-repair.ts). Before this class existed the ledger recorded the same
|
|
44
50
|
* root cause twice and nothing ever scheduled a fix, so it survived ~24h.
|
|
45
51
|
*/
|
|
46
|
-
export type DebtOrigin = 'accepted' | 'enforce-revert' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
|
|
52
|
+
export type DebtOrigin = 'accepted' | 'enforce-revert' | 'enforce-kept' | 'frozen-blocked' | 'cross-task-deletion' | 'yolo-accepted' | 'final-gate' | 'root-cause';
|
|
47
53
|
/** One recorded defect: the task, why its VERIFY failed, and how it was recorded. */
|
|
48
54
|
export interface AcceptDebt {
|
|
49
55
|
taskId: string;
|
|
@@ -82,6 +88,14 @@ export declare function recordAcceptDebt(cwd: string, taskId: string, reason: st
|
|
|
82
88
|
* rather than letting it die with the revert.
|
|
83
89
|
*/
|
|
84
90
|
export declare function recordEnforceRevertDebt(cwd: string, taskId: string, reason: string): Promise<void>;
|
|
91
|
+
/**
|
|
92
|
+
* Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
|
|
93
|
+
* on a check whose named files are DISJOINT from the enforce commit's own diff, so
|
|
94
|
+
* the edits were kept — discarding them could not have repaired a failure they cannot
|
|
95
|
+
* reach. The defect is real and still in the shipped tree, so it is recorded with the
|
|
96
|
+
* same durability as an enforce-revert; only the disposition of the edits differs.
|
|
97
|
+
*/
|
|
98
|
+
export declare function recordEnforceKeptDebt(cwd: string, taskId: string, reason: string): Promise<void>;
|
|
85
99
|
/**
|
|
86
100
|
* Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
|
|
87
101
|
* whose static findings can only be fixed by editing a path this task's spec froze —
|
package/dist/task/accept-debt.js
CHANGED
|
@@ -75,6 +75,7 @@ export function parseAcceptDebts(raw) {
|
|
|
75
75
|
taskId: parts[0].trim(),
|
|
76
76
|
reason: parts[1].trim(),
|
|
77
77
|
...((origin === 'enforce-revert'
|
|
78
|
+
|| origin === 'enforce-kept'
|
|
78
79
|
|| origin === 'frozen-blocked'
|
|
79
80
|
|| origin === 'cross-task-deletion'
|
|
80
81
|
|| origin === 'yolo-accepted'
|
|
@@ -147,6 +148,20 @@ export async function recordEnforceRevertDebt(cwd, taskId, reason) {
|
|
|
147
148
|
origin: 'enforce-revert'
|
|
148
149
|
});
|
|
149
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* Record an ENFORCE-KEPT debt (mx5 run 18 / nexttask 4): an enforce re-verify FAILED
|
|
153
|
+
* on a check whose named files are DISJOINT from the enforce commit's own diff, so
|
|
154
|
+
* the edits were kept — discarding them could not have repaired a failure they cannot
|
|
155
|
+
* reach. The defect is real and still in the shipped tree, so it is recorded with the
|
|
156
|
+
* same durability as an enforce-revert; only the disposition of the edits differs.
|
|
157
|
+
*/
|
|
158
|
+
export async function recordEnforceKeptDebt(cwd, taskId, reason) {
|
|
159
|
+
await appendDebt(cwd, {
|
|
160
|
+
taskId: taskId.trim(),
|
|
161
|
+
reason: normaliseReason(reason),
|
|
162
|
+
origin: 'enforce-kept'
|
|
163
|
+
});
|
|
164
|
+
}
|
|
150
165
|
/**
|
|
151
166
|
* Record a FROZEN-BLOCKED debt (mx5 run 12 / PROMPT 1 layer B): a repo-health FAIL
|
|
152
167
|
* whose static findings can only be fixed by editing a path this task's spec froze —
|
|
@@ -364,6 +379,9 @@ export function describeDebt(d) {
|
|
|
364
379
|
if (d.origin === 'enforce-revert') {
|
|
365
380
|
return 'enforce re-verify FAILED then the edits were reverted (defect indicts the ORIGINAL work, still shipped)';
|
|
366
381
|
}
|
|
382
|
+
if (d.origin === 'enforce-kept') {
|
|
383
|
+
return 'enforce re-verify FAILED on a check the enforce diff cannot reach — the guideline edits were KEPT (reverting them could not fix it) and the defect indicts the ORIGINAL work, still shipped';
|
|
384
|
+
}
|
|
367
385
|
if (d.origin === 'frozen-blocked') {
|
|
368
386
|
return 'repo health blocked by a spec-frozen path (cross-task contradiction — no task may perform the fixing edit)';
|
|
369
387
|
}
|
|
@@ -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) {
|