@mjasnikovs/pi-task 0.18.24 → 0.18.25
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.
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export interface RuntimeRef {
|
|
2
|
+
/** Repo-relative normalized path (posix separators, no leading ./). */
|
|
3
|
+
path: string;
|
|
4
|
+
/** Where the reference lives: repo-relative file, `package.json scripts.X`, or 'spec'. */
|
|
5
|
+
referencer: string;
|
|
6
|
+
/** The construct that matched (Bun.file, readFile, script src, …). */
|
|
7
|
+
construct: string;
|
|
8
|
+
/** file = must exist as a file; dir = a static root that must exist as a directory. */
|
|
9
|
+
kind: 'file' | 'dir';
|
|
10
|
+
}
|
|
11
|
+
export interface DanglingRef extends RuntimeRef {
|
|
12
|
+
/** Why the producer resolution came up empty (human- and prompt-readable). */
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
/** Everything the tree/scripts/build POSITIVELY produce. */
|
|
16
|
+
export interface ProducedOutputs {
|
|
17
|
+
/** Exact output files (tailwind -o, --outfile, redirects, Bun.write, cp dest…). */
|
|
18
|
+
files: Set<string>;
|
|
19
|
+
/** Enumerable outdir → output STEMS (basename sans extension) it emits —
|
|
20
|
+
* from parsed Bun.build entrypoints, explicit output files, source args. */
|
|
21
|
+
enumerable: Map<string, Set<string>>;
|
|
22
|
+
/** Dirs produced by machinery we could not enumerate (vite/tsc/next/unknown
|
|
23
|
+
* commands naming them) — everything under them steps aside. */
|
|
24
|
+
opaque: Set<string>;
|
|
25
|
+
/** Dirs known to be created (mkdir, outdirs) — satisfies dir-kind refs. */
|
|
26
|
+
dirs: Set<string>;
|
|
27
|
+
}
|
|
28
|
+
export declare function emptyProducers(): ProducedOutputs;
|
|
29
|
+
/** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
|
|
30
|
+
* tails (HTML), trailing slash. Returns null when the literal is not a
|
|
31
|
+
* checkable relative path (URL, absolute, template hole, glob — step aside). */
|
|
32
|
+
export declare function normalizeRefPath(raw: string): string | null;
|
|
33
|
+
/** Extract runtime refs from one JS/TS source. */
|
|
34
|
+
export declare function extractJsRefs(source: string, referencer: string): RuntimeRef[];
|
|
35
|
+
/** Extract local-asset refs from one HTML source. */
|
|
36
|
+
export declare function extractHtmlRefs(source: string, referencer: string): RuntimeRef[];
|
|
37
|
+
/** Script entrypoints: `bun x.ts`, `bun run x.ts`, `node x.js`, `tsx x.ts` —
|
|
38
|
+
* the first path-shaped source arg of a runner command. */
|
|
39
|
+
export declare function extractScriptEntrypoints(body: string, referencer: string): RuntimeRef[];
|
|
40
|
+
/**
|
|
41
|
+
* Producer facts from ONE shell command: output flags (`-o x`, `--outfile x`,
|
|
42
|
+
* `--outdir d`), redirects, `cp`/`mv`/`touch` destinations, `mkdir` dirs, known
|
|
43
|
+
* opaque bundlers. Leftover path tokens of an UNRECOGNIZED command escalate any
|
|
44
|
+
* directory they point into to opaque — that command may produce there, and
|
|
45
|
+
* inconclusive is never evidence. `escalate: false` disables that escalation
|
|
46
|
+
* for command text embedded in PROSE (a markdown bullet's surrounding words
|
|
47
|
+
* tokenize as junk "commands" and would opaque half the spec's paths).
|
|
48
|
+
*/
|
|
49
|
+
export declare function collectProducersFromCommand(cmd: string, prod: ProducedOutputs, opts?: {
|
|
50
|
+
escalate?: boolean;
|
|
51
|
+
}): void;
|
|
52
|
+
/**
|
|
53
|
+
* Producer facts from JS/TS source: write-side calls (`Bun.write`,
|
|
54
|
+
* `writeFile(Sync)`, `createWriteStream`, `copyFile` dest, `mkdir(Sync)`),
|
|
55
|
+
* `Bun.build({entrypoints, outdir})` (enumerable — unless a `naming` option
|
|
56
|
+
* makes the output names underivable, then opaque), `outfile:`, and `Bun.spawn`
|
|
57
|
+
* argv arrays re-fed through the shell-command collector (the mx5 build.ts
|
|
58
|
+
* shape: tailwind's `-o dist/app.css` lives in a spawn array).
|
|
59
|
+
*/
|
|
60
|
+
export declare function collectProducersFromSource(source: string, prod: ProducedOutputs): void;
|
|
61
|
+
/**
|
|
62
|
+
* Discover everything the project's own machinery produces: package.json script
|
|
63
|
+
* bodies, build files those scripts run (plus conventional root build files),
|
|
64
|
+
* tsconfig/vite outDirs.
|
|
65
|
+
*/
|
|
66
|
+
export declare function discoverProducers(cwd: string): ProducedOutputs;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve refs against existence + producers. DANGLING requires POSITIVE
|
|
69
|
+
* evidence (see the module doc): the ref sits under an ENUMERATED output dir
|
|
70
|
+
* and is not among its outputs, or is a missing source-only-extension file
|
|
71
|
+
* (nothing ever builds a `.ts`/`.tsx`). Everything inconclusive steps aside.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveDanglingRefs(refs: RuntimeRef[], prod: ProducedOutputs, exists: (rel: string) => boolean): DanglingRef[];
|
|
74
|
+
/**
|
|
75
|
+
* FINAL-GATE seam: scan the shipped tree for dangling runtime references.
|
|
76
|
+
* Deterministic, read-only, best-effort (throws nothing in normal operation;
|
|
77
|
+
* callers still guard). Producers are discovered first (scripts + build files +
|
|
78
|
+
* configs), then every authored source contributes refs AND runtime write-side
|
|
79
|
+
* producers (an app that writes its own cache file satisfies its own read).
|
|
80
|
+
*/
|
|
81
|
+
export declare function findDanglingArtifacts(cwd: string): DanglingRef[];
|
|
82
|
+
/** Ranked-failure text for the final gate (names referencer + missing path). */
|
|
83
|
+
export declare function danglingGateFailureText(d: DanglingRef): string;
|
|
84
|
+
/** Does the spec LIST the file as its own artifact — a file-tree entry or a
|
|
85
|
+
* bullet whose first token is (or ends with) the basename? Prose that merely
|
|
86
|
+
* mentions the name mid-sentence ("serves the built index.html") does NOT
|
|
87
|
+
* count: that is the CONSUMING side, exactly what must not self-satisfy. */
|
|
88
|
+
export declare function specListsFile(spec: string, refPath: string): boolean;
|
|
89
|
+
/** Backticked, asset-extension, path-shaped tokens on consuming-verb lines. */
|
|
90
|
+
export declare function extractSpecProseRefs(spec: string): RuntimeRef[];
|
|
91
|
+
/**
|
|
92
|
+
* PLAN-TIME seam: runtime refs in the spec's snippets AND consuming prose that
|
|
93
|
+
* neither the existing scaffold (`fileExists`), the spec's parsed build
|
|
94
|
+
* outputs, nor its own file tree produce. Each result becomes an UNOWNED
|
|
95
|
+
* coverage area until some task title claims the artifact.
|
|
96
|
+
*/
|
|
97
|
+
export declare function findSpecDanglingArtifacts(spec: string, fileExists: (rel: string) => boolean): DanglingRef[];
|
|
98
|
+
/** Does some task title claim the artifact (by basename or full path)? Titles
|
|
99
|
+
* are the one plan artifact the model cannot fake ownership INTO — mentioning
|
|
100
|
+
* the file is the grounded signal a producing task exists. */
|
|
101
|
+
export declare function titlesCoverArtifact(titles: string[], ref: {
|
|
102
|
+
path: string;
|
|
103
|
+
}): boolean;
|
|
104
|
+
/** Coverage-loop `missing` entry for an unowned dangling artifact. */
|
|
105
|
+
export declare function danglingMissingText(d: DanglingRef): string;
|
|
106
|
+
/** Carried-requirement line when still unowned at coverage exhaustion. */
|
|
107
|
+
export declare function danglingCarryText(d: DanglingRef): string;
|
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* artifact-closure — dangling runtime file references (the run-13 index.html
|
|
3
|
+
* class, nexttask PROMPT 2).
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (mx5 run 13, validated): a runtime file reference with
|
|
6
|
+
* NO producer anywhere in the plan shipped silently. The server's SPA fallback
|
|
7
|
+
* read `Bun.file('dist/index.html')`; the build emitted only `app.css` +
|
|
8
|
+
* `main.js`; no task, script, or build output ever CREATES `index.html` — so the
|
|
9
|
+
* shipped app 404'd on every non-API GET while coverage reported "0 unowned"
|
|
10
|
+
* (sentence-grounded coverage credited the SERVING side to the server task and
|
|
11
|
+
* was structurally blind to the missing PRODUCING side). The spec itself was
|
|
12
|
+
* internally dangling: its prose required serving `index.html` while its file
|
|
13
|
+
* tree and build section never defined it.
|
|
14
|
+
*
|
|
15
|
+
* Two seams consume this module:
|
|
16
|
+
* • plan-time (auto-orchestrator): refs extracted from the SPEC's own snippets
|
|
17
|
+
* that neither the spec's file tree, its build outputs, nor the existing
|
|
18
|
+
* scaffold produce become UNOWNED areas — they ride the coverage loop's
|
|
19
|
+
* `missing` list (forcing a round that assigns a producing task) and are
|
|
20
|
+
* carried into `.pi-tasks/requirements.md` when still unowned at exhaustion.
|
|
21
|
+
* • final gate: the shipped tree is scanned; a dangling reference is a ranked
|
|
22
|
+
* failure naming referencer + missing path (rides PROMPT 1's aggregation).
|
|
23
|
+
*
|
|
24
|
+
* FP discipline (the run-12 groundedCoverage lesson — ground in artifacts the
|
|
25
|
+
* model can't fake, and the standing guard direction — inconclusive is NEVER
|
|
26
|
+
* evidence):
|
|
27
|
+
* • literal string paths only; any dynamic expression steps aside.
|
|
28
|
+
* • a ref is DANGLING only on POSITIVE producer evidence: it must sit under a
|
|
29
|
+
* directory whose outputs we could actually ENUMERATE (parsed build
|
|
30
|
+
* script/flags) and not be among them — or be a missing source-extension
|
|
31
|
+
* script entrypoint, which nothing ever builds. A ref under a directory
|
|
32
|
+
* produced by machinery we could NOT enumerate (vite/tsc/next/unknown
|
|
33
|
+
* commands that mention it) is OPAQUE and always steps aside.
|
|
34
|
+
* • gitignored-but-built paths therefore never fire: being built means a
|
|
35
|
+
* producer names them (exact file, enumerable stem, or opaque dir).
|
|
36
|
+
* • existence is checked on the live tree (existsSync), so anything already
|
|
37
|
+
* present — committed, generated, or hand-made — is satisfied.
|
|
38
|
+
*
|
|
39
|
+
* Note the mx5 server GUARDED its read (`if (!(await htmlFile.exists())) return
|
|
40
|
+
* c.notFound()`): an existence guard is exactly how the bug presents (permanent
|
|
41
|
+
* 404), so guarded reads deliberately do NOT step aside.
|
|
42
|
+
*/
|
|
43
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
44
|
+
import * as path from 'node:path';
|
|
45
|
+
export function emptyProducers() {
|
|
46
|
+
return { files: new Set(), enumerable: new Map(), opaque: new Set(), dirs: new Set() };
|
|
47
|
+
}
|
|
48
|
+
/** Normalize a literal path: posix separators, strip ./ prefixes, query/hash
|
|
49
|
+
* tails (HTML), trailing slash. Returns null when the literal is not a
|
|
50
|
+
* checkable relative path (URL, absolute, template hole, glob — step aside). */
|
|
51
|
+
export function normalizeRefPath(raw) {
|
|
52
|
+
let p = raw.trim().replace(/\\/g, '/');
|
|
53
|
+
// Markdown/shell wrapping (a spec bullet's `-o dist/app.css` arrives with a
|
|
54
|
+
// trailing backtick) — strip wrapping quote characters at both ends.
|
|
55
|
+
p = p.replace(/^[`'"]+/, '').replace(/[`'"]+$/, '');
|
|
56
|
+
// HTML-side noise: ?query / #fragment tails.
|
|
57
|
+
p = p.replace(/[?#].*$/, '');
|
|
58
|
+
if (p.length === 0 || p.length > 200)
|
|
59
|
+
return null;
|
|
60
|
+
// Dynamic/glob/scheme/absolute — inconclusive, never evidence. A remaining
|
|
61
|
+
// quote or whitespace mid-token means we mis-tokenized — step aside.
|
|
62
|
+
if (/\$\{|[*?]|^[a-z][a-z0-9+.-]*:|^\/\/|^~|[`'"\s]/i.test(p))
|
|
63
|
+
return null;
|
|
64
|
+
while (p.startsWith('./'))
|
|
65
|
+
p = p.slice(2);
|
|
66
|
+
if (p.startsWith('/'))
|
|
67
|
+
p = p.slice(1); // root-relative (HTML) → resolve from repo root
|
|
68
|
+
p = p.replace(/\/+$/, '');
|
|
69
|
+
if (p.length === 0 || p === '.' || p === '..')
|
|
70
|
+
return null;
|
|
71
|
+
if (p.startsWith('node_modules/') || p.includes('/node_modules/'))
|
|
72
|
+
return null;
|
|
73
|
+
return p;
|
|
74
|
+
}
|
|
75
|
+
const stem = (p) => path.posix.basename(p).replace(/\.[^.]+$/, '');
|
|
76
|
+
const hasExt = (p) => /\.[A-Za-z0-9]{1,8}$/.test(path.posix.basename(p));
|
|
77
|
+
/** Source-only extensions nothing ships un-built: a missing one of these is hard
|
|
78
|
+
* evidence on its own (a script entrypoint like `bun src/server/index.ts`). */
|
|
79
|
+
const SOURCE_ONLY_EXT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/i;
|
|
80
|
+
/** Strip comment-only lines (`// …`, `* …`) — a ref quoted in a comment is not a
|
|
81
|
+
* runtime read. Inline comments are left alone (strings may contain `//`). */
|
|
82
|
+
function stripCommentLines(src) {
|
|
83
|
+
return src
|
|
84
|
+
.split('\n')
|
|
85
|
+
.filter(l => !/^\s*(?:\/\/|\*|\/\*)/.test(l))
|
|
86
|
+
.join('\n');
|
|
87
|
+
}
|
|
88
|
+
// Literal-first-argument read-side constructs. `[^'"\`\n]` keeps the argument on
|
|
89
|
+
// one line and free of a closing quote; normalizeRefPath rejects `${…}` holes.
|
|
90
|
+
const JS_READ_PATTERNS = [
|
|
91
|
+
{ re: /\bBun\.file\(\s*(['"`])([^'"`\n]+)\1/g, construct: 'Bun.file', kind: 'file' },
|
|
92
|
+
{ re: /\breadFile(?:Sync)?\(\s*(['"`])([^'"`\n]+)\1/g, construct: 'readFile', kind: 'file' },
|
|
93
|
+
{
|
|
94
|
+
re: /\bcreateReadStream\(\s*(['"`])([^'"`\n]+)\1/g,
|
|
95
|
+
construct: 'createReadStream',
|
|
96
|
+
kind: 'file'
|
|
97
|
+
},
|
|
98
|
+
{ re: /\bsendFile\(\s*(['"`])([^'"`\n]+)\1/g, construct: 'sendFile', kind: 'file' },
|
|
99
|
+
{
|
|
100
|
+
re: /\bserveStatic\(\s*\{[^}]*?\broot:\s*(['"`])([^'"`\n]+)\1/g,
|
|
101
|
+
construct: 'serveStatic root',
|
|
102
|
+
kind: 'dir'
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
re: /\b(?:express|app)\.static\(\s*(['"`])([^'"`\n]+)\1/g,
|
|
106
|
+
construct: 'express.static root',
|
|
107
|
+
kind: 'dir'
|
|
108
|
+
}
|
|
109
|
+
];
|
|
110
|
+
// Local-asset references in HTML: script/link/img only — an <a href> is a route,
|
|
111
|
+
// not a file the server must materialize.
|
|
112
|
+
const HTML_PATTERNS = [
|
|
113
|
+
{ re: /<script\b[^>]*\bsrc\s*=\s*(['"])([^'"]+)\1/gi, construct: 'script src', kind: 'file' },
|
|
114
|
+
{ re: /<link\b[^>]*\bhref\s*=\s*(['"])([^'"]+)\1/gi, construct: 'link href', kind: 'file' },
|
|
115
|
+
{ re: /<img\b[^>]*\bsrc\s*=\s*(['"])([^'"]+)\1/gi, construct: 'img src', kind: 'file' }
|
|
116
|
+
];
|
|
117
|
+
/** Extract runtime refs from one JS/TS source. */
|
|
118
|
+
export function extractJsRefs(source, referencer) {
|
|
119
|
+
const src = stripCommentLines(source);
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const { re, construct, kind } of JS_READ_PATTERNS) {
|
|
122
|
+
re.lastIndex = 0;
|
|
123
|
+
for (let m = re.exec(src); m !== null; m = re.exec(src)) {
|
|
124
|
+
const p = normalizeRefPath(m[2]);
|
|
125
|
+
if (p === null)
|
|
126
|
+
continue;
|
|
127
|
+
if (kind === 'file' && !hasExt(p))
|
|
128
|
+
continue; // route-/name-shaped, not a file
|
|
129
|
+
out.push({ path: p, referencer, construct, kind });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
/** Extract local-asset refs from one HTML source. */
|
|
135
|
+
export function extractHtmlRefs(source, referencer) {
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const { re, construct, kind } of HTML_PATTERNS) {
|
|
138
|
+
re.lastIndex = 0;
|
|
139
|
+
for (let m = re.exec(source); m !== null; m = re.exec(source)) {
|
|
140
|
+
const p = normalizeRefPath(m[2]);
|
|
141
|
+
if (p === null)
|
|
142
|
+
continue;
|
|
143
|
+
if (!hasExt(p))
|
|
144
|
+
continue; // extension-less href/src = route or directory
|
|
145
|
+
out.push({ path: p, referencer, construct, kind });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
/** A path-shaped shell token: contains a separator or an extension, no shell
|
|
151
|
+
* metacharacters, not a flag. */
|
|
152
|
+
function isPathToken(t) {
|
|
153
|
+
if (t.startsWith('-') || t.length === 0 || t.length > 200)
|
|
154
|
+
return false;
|
|
155
|
+
if (/["'`$(){}<>|;&*?[\]]/.test(t))
|
|
156
|
+
return false;
|
|
157
|
+
return t.includes('/') || hasExt(t);
|
|
158
|
+
}
|
|
159
|
+
/** Script entrypoints: `bun x.ts`, `bun run x.ts`, `node x.js`, `tsx x.ts` —
|
|
160
|
+
* the first path-shaped source arg of a runner command. */
|
|
161
|
+
export function extractScriptEntrypoints(body, referencer) {
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const cmd of splitShellCommands(body)) {
|
|
164
|
+
const toks = cmd.trim().split(/\s+/);
|
|
165
|
+
let i = 0;
|
|
166
|
+
if (['bun', 'bunx', 'node', 'tsx', 'deno'].includes(toks[i] ?? '')) {
|
|
167
|
+
i++;
|
|
168
|
+
if (toks[i] === 'run')
|
|
169
|
+
i++;
|
|
170
|
+
while (toks[i]?.startsWith('-'))
|
|
171
|
+
i++;
|
|
172
|
+
const cand = toks[i];
|
|
173
|
+
if (cand !== undefined && isPathToken(cand) && /\.(?:ts|tsx|js|mjs|cjs)$/.test(cand)) {
|
|
174
|
+
const p = normalizeRefPath(cand);
|
|
175
|
+
if (p !== null)
|
|
176
|
+
out.push({ path: p, referencer, construct: 'script entrypoint', kind: 'file' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
/** Split a package.json script body into individual commands (`&&`, `||`, `;`,
|
|
183
|
+
* `|`, background `&`, newlines). */
|
|
184
|
+
function splitShellCommands(body) {
|
|
185
|
+
return body
|
|
186
|
+
.split(/&&|\|\||[;|&]|\n/)
|
|
187
|
+
.map(s => s.trim())
|
|
188
|
+
.filter(s => s.length > 0);
|
|
189
|
+
}
|
|
190
|
+
function addEnumerable(prod, dir, stems) {
|
|
191
|
+
const d = prod.enumerable.get(dir) ?? new Set();
|
|
192
|
+
for (const s of stems)
|
|
193
|
+
d.add(s);
|
|
194
|
+
prod.enumerable.set(dir, d);
|
|
195
|
+
prod.dirs.add(dir);
|
|
196
|
+
}
|
|
197
|
+
function addFile(prod, file) {
|
|
198
|
+
prod.files.add(file);
|
|
199
|
+
const dir = path.posix.dirname(file);
|
|
200
|
+
if (dir !== '.')
|
|
201
|
+
addEnumerable(prod, dir, [stem(file)]);
|
|
202
|
+
}
|
|
203
|
+
/** Tools that never materialize project files on their own — their leftover path
|
|
204
|
+
* args must not escalate a directory to opaque. */
|
|
205
|
+
const KNOWN_NON_PRODUCING_RE = /^(?:prettier|eslint|tsc|bun|bunx|npx|node|tsx|deno|jest|vitest|mocha|playwright|cypress|grep|cat|echo|rm|test|cross-env|env|git|curl|wget|true|false|exit|sleep|kill|mkdir|cp|mv|touch|concurrently|npm-run-all|wait-on)$/;
|
|
206
|
+
/** Bundlers/compilers whose OUTPUT DIRECTORY is known but whose exact output
|
|
207
|
+
* file set we cannot enumerate statically — the dir becomes opaque. */
|
|
208
|
+
const OPAQUE_TOOL_DIRS = [
|
|
209
|
+
{ re: /\bvite\s+build\b|\bvite\b\s*$/, dirs: ['dist'] },
|
|
210
|
+
{ re: /\bnext\s+build\b/, dirs: ['.next', 'out'] },
|
|
211
|
+
{ re: /\btsup\b/, dirs: ['dist'] },
|
|
212
|
+
{ re: /\bwebpack\b/, dirs: ['dist', 'build'] },
|
|
213
|
+
{ re: /\brollup\b/, dirs: ['dist'] },
|
|
214
|
+
{ re: /\bparcel\s+build\b/, dirs: ['dist'] },
|
|
215
|
+
{ re: /\breact-scripts\s+build\b/, dirs: ['build'] },
|
|
216
|
+
{ re: /\bng\s+build\b/, dirs: ['dist'] },
|
|
217
|
+
{ re: /\bastro\s+build\b/, dirs: ['dist'] },
|
|
218
|
+
{ re: /\bnuxt\s+(?:build|generate)\b/, dirs: ['.output', 'dist'] }
|
|
219
|
+
];
|
|
220
|
+
/**
|
|
221
|
+
* Producer facts from ONE shell command: output flags (`-o x`, `--outfile x`,
|
|
222
|
+
* `--outdir d`), redirects, `cp`/`mv`/`touch` destinations, `mkdir` dirs, known
|
|
223
|
+
* opaque bundlers. Leftover path tokens of an UNRECOGNIZED command escalate any
|
|
224
|
+
* directory they point into to opaque — that command may produce there, and
|
|
225
|
+
* inconclusive is never evidence. `escalate: false` disables that escalation
|
|
226
|
+
* for command text embedded in PROSE (a markdown bullet's surrounding words
|
|
227
|
+
* tokenize as junk "commands" and would opaque half the spec's paths).
|
|
228
|
+
*/
|
|
229
|
+
export function collectProducersFromCommand(cmd, prod, opts = {}) {
|
|
230
|
+
let rest = cmd.replace(/2>&1|&>\s*\S+|2>\s*\S+/g, ' ');
|
|
231
|
+
for (const { re, dirs } of OPAQUE_TOOL_DIRS) {
|
|
232
|
+
if (re.test(rest))
|
|
233
|
+
for (const d of dirs)
|
|
234
|
+
prod.opaque.add(d);
|
|
235
|
+
}
|
|
236
|
+
// Redirect target.
|
|
237
|
+
rest = rest.replace(/>>?\s*([^\s&|;]+)/g, (_, f) => {
|
|
238
|
+
const p = normalizeRefPath(f);
|
|
239
|
+
if (p !== null && p !== 'dev/null')
|
|
240
|
+
addFile(prod, p);
|
|
241
|
+
return ' ';
|
|
242
|
+
});
|
|
243
|
+
// Explicit output flags. --outdir/--out-dir is always a dir; -o/--output/
|
|
244
|
+
// --outfile decide by extension (tailwind -o dist/app.css vs esbuild -o dir).
|
|
245
|
+
const flagRe = /(?:^|\s)(--out(?:file|put|dir|-dir)?|-o)(?:=|\s+)([^\s&|;]+)/g;
|
|
246
|
+
const sourceArgs = [];
|
|
247
|
+
rest = rest.replace(flagRe, (_, flag, val) => {
|
|
248
|
+
const p = normalizeRefPath(val);
|
|
249
|
+
if (p === null)
|
|
250
|
+
return ' ';
|
|
251
|
+
if (flag === '--outdir' || flag === '--out-dir')
|
|
252
|
+
addEnumerable(prod, p, []);
|
|
253
|
+
else if (hasExt(p))
|
|
254
|
+
addFile(prod, p);
|
|
255
|
+
else
|
|
256
|
+
addEnumerable(prod, p, []);
|
|
257
|
+
return ' ';
|
|
258
|
+
});
|
|
259
|
+
const toks = rest.trim().split(/\s+/);
|
|
260
|
+
const bin = (toks[0] ?? '').replace(/^.*\//, '');
|
|
261
|
+
if (bin === 'mkdir') {
|
|
262
|
+
for (const t of toks.slice(1)) {
|
|
263
|
+
const p = t.startsWith('-') ? null : normalizeRefPath(t);
|
|
264
|
+
if (p !== null)
|
|
265
|
+
prod.dirs.add(p);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (bin === 'cp' || bin === 'mv') {
|
|
270
|
+
const args = toks.slice(1).filter(t => !t.startsWith('-'));
|
|
271
|
+
const dest = args[args.length - 1];
|
|
272
|
+
const p = dest !== undefined ? normalizeRefPath(dest) : null;
|
|
273
|
+
if (p !== null && args.length >= 2) {
|
|
274
|
+
if (hasExt(p))
|
|
275
|
+
addFile(prod, p);
|
|
276
|
+
else
|
|
277
|
+
prod.opaque.add(p); // dir dest: contents unenumerable
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (bin === 'touch') {
|
|
282
|
+
for (const t of toks.slice(1)) {
|
|
283
|
+
const p = t.startsWith('-') ? null : normalizeRefPath(t);
|
|
284
|
+
if (p !== null)
|
|
285
|
+
addFile(prod, p);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
// Source-shaped args (a bundler's entrypoints) contribute stems to any
|
|
290
|
+
// outdir this same command declared.
|
|
291
|
+
for (const t of toks.slice(1)) {
|
|
292
|
+
if (isPathToken(t) && /\.(?:ts|tsx|js|jsx|mjs|cjs|css|html)$/.test(t)) {
|
|
293
|
+
const p = normalizeRefPath(t);
|
|
294
|
+
if (p !== null)
|
|
295
|
+
sourceArgs.push(p);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// (flagRe already consumed outdirs; attribute stems to dirs declared here.)
|
|
299
|
+
// Re-scan the ORIGINAL command for the outdirs it declared:
|
|
300
|
+
const declaredDirs = [];
|
|
301
|
+
const dirFlagRe = /(?:^|\s)--out(?:dir|-dir)(?:=|\s+)([^\s&|;]+)/g;
|
|
302
|
+
for (let m = dirFlagRe.exec(cmd); m !== null; m = dirFlagRe.exec(cmd)) {
|
|
303
|
+
const p = normalizeRefPath(m[1]);
|
|
304
|
+
if (p !== null)
|
|
305
|
+
declaredDirs.push(p);
|
|
306
|
+
}
|
|
307
|
+
for (const d of declaredDirs)
|
|
308
|
+
addEnumerable(prod, d, sourceArgs.map(stem));
|
|
309
|
+
// Unrecognized command: any leftover path token pointing INTO a directory
|
|
310
|
+
// makes that directory opaque — the tool may generate arbitrary files there.
|
|
311
|
+
if ((opts.escalate ?? true) && !KNOWN_NON_PRODUCING_RE.test(bin) && !/^@/.test(bin)) {
|
|
312
|
+
for (const t of toks.slice(1)) {
|
|
313
|
+
if (!isPathToken(t))
|
|
314
|
+
continue;
|
|
315
|
+
const p = normalizeRefPath(t);
|
|
316
|
+
if (p === null)
|
|
317
|
+
continue;
|
|
318
|
+
const dir = hasExt(p) ? path.posix.dirname(p) : p;
|
|
319
|
+
if (dir !== '.')
|
|
320
|
+
prod.opaque.add(dir);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Producer facts from JS/TS source: write-side calls (`Bun.write`,
|
|
326
|
+
* `writeFile(Sync)`, `createWriteStream`, `copyFile` dest, `mkdir(Sync)`),
|
|
327
|
+
* `Bun.build({entrypoints, outdir})` (enumerable — unless a `naming` option
|
|
328
|
+
* makes the output names underivable, then opaque), `outfile:`, and `Bun.spawn`
|
|
329
|
+
* argv arrays re-fed through the shell-command collector (the mx5 build.ts
|
|
330
|
+
* shape: tailwind's `-o dist/app.css` lives in a spawn array).
|
|
331
|
+
*/
|
|
332
|
+
export function collectProducersFromSource(source, prod) {
|
|
333
|
+
const src = stripCommentLines(source);
|
|
334
|
+
const litRe = (call) => new RegExp(String.raw `\b${call}\(\s*(['"\`])([^'"\`\n]+)\1`, 'g');
|
|
335
|
+
for (const call of [
|
|
336
|
+
'Bun\\.write',
|
|
337
|
+
'writeFile(?:Sync)?',
|
|
338
|
+
'appendFile(?:Sync)?',
|
|
339
|
+
'createWriteStream'
|
|
340
|
+
]) {
|
|
341
|
+
const re = litRe(call);
|
|
342
|
+
for (let m = re.exec(src); m !== null; m = re.exec(src)) {
|
|
343
|
+
const p = normalizeRefPath(m[2]);
|
|
344
|
+
if (p !== null && hasExt(p))
|
|
345
|
+
addFile(prod, p);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const cpRe = /\bcopyFile(?:Sync)?\(\s*[^,()]+,\s*(['"`])([^'"`\n]+)\1/g;
|
|
349
|
+
for (let m = cpRe.exec(src); m !== null; m = cpRe.exec(src)) {
|
|
350
|
+
const p = normalizeRefPath(m[2]);
|
|
351
|
+
if (p !== null)
|
|
352
|
+
addFile(prod, p);
|
|
353
|
+
}
|
|
354
|
+
const mkRe = /\bmkdir(?:Sync)?\(\s*(['"`])([^'"`\n]+)\1/g;
|
|
355
|
+
for (let m = mkRe.exec(src); m !== null; m = mkRe.exec(src)) {
|
|
356
|
+
const p = normalizeRefPath(m[2]);
|
|
357
|
+
if (p !== null)
|
|
358
|
+
prod.dirs.add(p);
|
|
359
|
+
}
|
|
360
|
+
// Bun.build blocks: pair each `outdir` with the `entrypoints` in the same
|
|
361
|
+
// options object (nearest preceding within the call's brace span — a simple
|
|
362
|
+
// window keeps this robust to formatting without a real parser).
|
|
363
|
+
const buildRe = /Bun\.build\(\s*\{([\s\S]{0,2000}?)\}\s*\)/g;
|
|
364
|
+
for (let m = buildRe.exec(src); m !== null; m = buildRe.exec(src)) {
|
|
365
|
+
const body = m[1];
|
|
366
|
+
const outdirM = /\boutdir:\s*(['"`])([^'"`\n]+)\1/.exec(body);
|
|
367
|
+
const outfileM = /\boutfile:\s*(['"`])([^'"`\n]+)\1/.exec(body);
|
|
368
|
+
if (outfileM) {
|
|
369
|
+
const p = normalizeRefPath(outfileM[2]);
|
|
370
|
+
if (p !== null)
|
|
371
|
+
addFile(prod, p);
|
|
372
|
+
}
|
|
373
|
+
if (!outdirM)
|
|
374
|
+
continue;
|
|
375
|
+
const dir = normalizeRefPath(outdirM[2]);
|
|
376
|
+
if (dir === null)
|
|
377
|
+
continue;
|
|
378
|
+
if (/\bnaming:/.test(body)) {
|
|
379
|
+
prod.opaque.add(dir); // custom naming — outputs underivable
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const entryM = /\bentrypoints:\s*\[([^\]]*)\]/.exec(body);
|
|
383
|
+
const entries = [];
|
|
384
|
+
if (entryM) {
|
|
385
|
+
const lit = /(['"`])([^'"`\n]+)\1/g;
|
|
386
|
+
for (let em = lit.exec(entryM[1]); em !== null; em = lit.exec(entryM[1])) {
|
|
387
|
+
const p = normalizeRefPath(em[2]);
|
|
388
|
+
if (p !== null)
|
|
389
|
+
entries.push(p);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (entries.length === 0 && entryM === null) {
|
|
393
|
+
prod.opaque.add(dir); // dynamic entrypoints — cannot enumerate
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
addEnumerable(prod, dir, entries.map(stem));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// Bun.spawn / spawnSync argv arrays → re-parse as a shell command.
|
|
400
|
+
const spawnRe = /\bspawn(?:Sync)?\(\s*\[([^\]]*)\]/gi;
|
|
401
|
+
for (let m = spawnRe.exec(src); m !== null; m = spawnRe.exec(src)) {
|
|
402
|
+
const lit = /(['"`])([^'"`\n]*)\1/g;
|
|
403
|
+
const argv = [];
|
|
404
|
+
for (let am = lit.exec(m[1]); am !== null; am = lit.exec(m[1]))
|
|
405
|
+
argv.push(am[2]);
|
|
406
|
+
if (argv.length > 0)
|
|
407
|
+
collectProducersFromCommand(argv.join(' '), prod);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** Best-effort `outDir` from tsconfig-family files (JSONC-tolerant). tsc mirrors
|
|
411
|
+
* arbitrary source names into it, so it is always OPAQUE. */
|
|
412
|
+
function collectTsconfigOutDirs(cwd, prod) {
|
|
413
|
+
let names;
|
|
414
|
+
try {
|
|
415
|
+
names = readdirSync(cwd).filter(n => /^tsconfig(\..+)?\.json$/.test(n));
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
for (const n of names) {
|
|
421
|
+
try {
|
|
422
|
+
const m = /"outDir"\s*:\s*"([^"]+)"/.exec(readFileSync(path.join(cwd, n), 'utf8'));
|
|
423
|
+
if (m) {
|
|
424
|
+
const p = normalizeRefPath(m[1]);
|
|
425
|
+
if (p !== null)
|
|
426
|
+
prod.opaque.add(p);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// unreadable config — nothing to learn
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** vite/astro-style config: `outDir: 'x'` (opaque), plus their default `dist`. */
|
|
435
|
+
function collectBundlerConfigOutDirs(cwd, prod) {
|
|
436
|
+
for (const n of [
|
|
437
|
+
'vite.config.ts',
|
|
438
|
+
'vite.config.js',
|
|
439
|
+
'vite.config.mts',
|
|
440
|
+
'vite.config.mjs',
|
|
441
|
+
'astro.config.mjs',
|
|
442
|
+
'astro.config.ts'
|
|
443
|
+
]) {
|
|
444
|
+
const f = path.join(cwd, n);
|
|
445
|
+
if (!existsSync(f))
|
|
446
|
+
continue;
|
|
447
|
+
prod.opaque.add('dist');
|
|
448
|
+
try {
|
|
449
|
+
const m = /\boutDir:\s*(['"`])([^'"`\n]+)\1/.exec(readFileSync(f, 'utf8'));
|
|
450
|
+
if (m) {
|
|
451
|
+
const p = normalizeRefPath(m[2]);
|
|
452
|
+
if (p !== null)
|
|
453
|
+
prod.opaque.add(p);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// default already recorded
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/** package.json scripts of `cwd` (empty on any fault). */
|
|
462
|
+
function packageScripts(cwd) {
|
|
463
|
+
try {
|
|
464
|
+
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
465
|
+
return j.scripts ?? {};
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return {};
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Discover everything the project's own machinery produces: package.json script
|
|
473
|
+
* bodies, build files those scripts run (plus conventional root build files),
|
|
474
|
+
* tsconfig/vite outDirs.
|
|
475
|
+
*/
|
|
476
|
+
export function discoverProducers(cwd) {
|
|
477
|
+
const prod = emptyProducers();
|
|
478
|
+
const scripts = packageScripts(cwd);
|
|
479
|
+
const buildFiles = new Set(['build.ts', 'build.js', 'build.mjs'].filter(f => existsSync(path.join(cwd, f))));
|
|
480
|
+
for (const body of Object.values(scripts)) {
|
|
481
|
+
for (const cmd of splitShellCommands(body)) {
|
|
482
|
+
collectProducersFromCommand(cmd, prod);
|
|
483
|
+
// A script that runs a local JS/TS file may produce through it —
|
|
484
|
+
// parse that file's source too (the mx5 `bun build.ts` shape).
|
|
485
|
+
for (const t of cmd.split(/\s+/)) {
|
|
486
|
+
if (isPathToken(t) && /\.(?:ts|js|mjs|cjs)$/.test(t)) {
|
|
487
|
+
const p = normalizeRefPath(t);
|
|
488
|
+
if (p !== null && existsSync(path.join(cwd, p)))
|
|
489
|
+
buildFiles.add(p);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const f of buildFiles) {
|
|
495
|
+
try {
|
|
496
|
+
collectProducersFromSource(readFileSync(path.join(cwd, f), 'utf8'), prod);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
// unreadable build file: its outdirs stay whatever the scripts said
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
collectTsconfigOutDirs(cwd, prod);
|
|
503
|
+
collectBundlerConfigOutDirs(cwd, prod);
|
|
504
|
+
return prod;
|
|
505
|
+
}
|
|
506
|
+
const underDir = (p, dir) => p === dir || p.startsWith(dir + '/');
|
|
507
|
+
/**
|
|
508
|
+
* Resolve refs against existence + producers. DANGLING requires POSITIVE
|
|
509
|
+
* evidence (see the module doc): the ref sits under an ENUMERATED output dir
|
|
510
|
+
* and is not among its outputs, or is a missing source-only-extension file
|
|
511
|
+
* (nothing ever builds a `.ts`/`.tsx`). Everything inconclusive steps aside.
|
|
512
|
+
*/
|
|
513
|
+
export function resolveDanglingRefs(refs, prod, exists) {
|
|
514
|
+
const out = [];
|
|
515
|
+
const seen = new Set();
|
|
516
|
+
for (const ref of refs) {
|
|
517
|
+
const p = ref.path;
|
|
518
|
+
// Candidate bases: repo root and the referencing file's directory (a JS
|
|
519
|
+
// relative read may resolve from either at runtime; HTML relative src
|
|
520
|
+
// resolves from its own dir). Satisfied under ANY plausible base.
|
|
521
|
+
const bases = [''];
|
|
522
|
+
const refDir = path.posix.dirname(ref.referencer.replace(/\\/g, '/'));
|
|
523
|
+
if (refDir !== '.'
|
|
524
|
+
&& !ref.referencer.startsWith('package.json')
|
|
525
|
+
&& ref.referencer !== 'spec') {
|
|
526
|
+
bases.push(refDir);
|
|
527
|
+
}
|
|
528
|
+
const candidates = bases
|
|
529
|
+
.map(b => path.posix.normalize(b === '' ? p : `${b}/${p}`))
|
|
530
|
+
.filter(c => !c.startsWith('..'));
|
|
531
|
+
if (candidates.length === 0)
|
|
532
|
+
continue; // escapes the repo — cannot ground
|
|
533
|
+
if (candidates.some(c => exists(c)))
|
|
534
|
+
continue;
|
|
535
|
+
if (ref.kind === 'dir') {
|
|
536
|
+
const satisfied = candidates.some(c => prod.dirs.has(c)
|
|
537
|
+
|| prod.opaque.has(c)
|
|
538
|
+
|| prod.enumerable.has(c)
|
|
539
|
+
|| [...prod.files].some(f => underDir(f, c))
|
|
540
|
+
|| [...prod.opaque].some(d => underDir(c, d)));
|
|
541
|
+
if (satisfied)
|
|
542
|
+
continue;
|
|
543
|
+
// A static root that neither exists nor is produced is only flagged
|
|
544
|
+
// when the project HAS producer machinery at all — a bare repo with
|
|
545
|
+
// no scripts yields no evidence either way.
|
|
546
|
+
if (prod.dirs.size + prod.enumerable.size + prod.opaque.size + prod.files.size === 0) {
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
push(ref, `directory does not exist and no script or build step creates it`);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
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
|
+
});
|
|
563
|
+
if (isSatisfied)
|
|
564
|
+
continue;
|
|
565
|
+
// Positive-evidence branches:
|
|
566
|
+
const underEnumerable = candidates.some(c => [...prod.enumerable.keys()].some(d => underDir(path.posix.dirname(c), d)));
|
|
567
|
+
if (underEnumerable) {
|
|
568
|
+
push(ref, 'it sits in a build output directory whose parsed outputs do not include it');
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (SOURCE_ONLY_EXT_RE.test(p) && ref.construct === 'script entrypoint') {
|
|
572
|
+
push(ref, 'the script entrypoint file does not exist and nothing generates sources');
|
|
573
|
+
}
|
|
574
|
+
// Everything else: inconclusive — step aside.
|
|
575
|
+
}
|
|
576
|
+
return out;
|
|
577
|
+
function push(ref, reason) {
|
|
578
|
+
const key = `${ref.referencer}${ref.path}`;
|
|
579
|
+
if (seen.has(key))
|
|
580
|
+
return;
|
|
581
|
+
seen.add(key);
|
|
582
|
+
out.push({ ...ref, reason });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
// Directories never scanned for referencing sources: VCS/dep/artifact trees,
|
|
586
|
+
// every discovered produced dir (bundled output re-referencing its own chunks is
|
|
587
|
+
// noise), and test/fixture/doc trees — those reference fixture paths and quoted
|
|
588
|
+
// examples freely and are not the runtime serving surface this guard protects.
|
|
589
|
+
const SKIP_DIR_RE = /^(?:\.git|node_modules|\.pi-tasks|dist|build|out|coverage|target|vendor|__pycache__|\.venv|venv|tmp|test|tests|__tests__|__fixtures__|fixtures|e2e|examples|example|docs|doc)$/;
|
|
590
|
+
const SKIP_FILE_RE = /\.(?:test|spec|stories)\.[a-z]+$|\.d\.[mc]?ts$/i;
|
|
591
|
+
const SCAN_JS_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/i;
|
|
592
|
+
const SCAN_HTML_RE = /\.html?$/i;
|
|
593
|
+
const MAX_SCAN_FILES = 3000;
|
|
594
|
+
const MAX_FILE_BYTES = 400_000;
|
|
595
|
+
/** Walk the tree for scannable sources (bounded, deterministic order). */
|
|
596
|
+
function scanCandidates(cwd, prod) {
|
|
597
|
+
const out = [];
|
|
598
|
+
const producedDirs = new Set([...prod.dirs, ...prod.opaque, ...prod.enumerable.keys()].map(d => d.split('/')[0]));
|
|
599
|
+
const walk = (rel) => {
|
|
600
|
+
if (out.length >= MAX_SCAN_FILES)
|
|
601
|
+
return;
|
|
602
|
+
let entries;
|
|
603
|
+
try {
|
|
604
|
+
entries = readdirSync(path.join(cwd, rel)).sort();
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
for (const name of entries) {
|
|
610
|
+
if (out.length >= MAX_SCAN_FILES)
|
|
611
|
+
return;
|
|
612
|
+
const relPath = rel === '' ? name : `${rel}/${name}`;
|
|
613
|
+
let st;
|
|
614
|
+
try {
|
|
615
|
+
st = statSync(path.join(cwd, relPath));
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
if (st.isDirectory()) {
|
|
621
|
+
if (name.startsWith('.') || SKIP_DIR_RE.test(name))
|
|
622
|
+
continue;
|
|
623
|
+
if (rel === '' && producedDirs.has(name))
|
|
624
|
+
continue;
|
|
625
|
+
walk(relPath);
|
|
626
|
+
}
|
|
627
|
+
else if (st.isFile() && st.size <= MAX_FILE_BYTES) {
|
|
628
|
+
if (SKIP_FILE_RE.test(name))
|
|
629
|
+
continue;
|
|
630
|
+
if (SCAN_JS_RE.test(name) || SCAN_HTML_RE.test(name))
|
|
631
|
+
out.push(relPath);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
walk('');
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* FINAL-GATE seam: scan the shipped tree for dangling runtime references.
|
|
640
|
+
* Deterministic, read-only, best-effort (throws nothing in normal operation;
|
|
641
|
+
* callers still guard). Producers are discovered first (scripts + build files +
|
|
642
|
+
* configs), then every authored source contributes refs AND runtime write-side
|
|
643
|
+
* producers (an app that writes its own cache file satisfies its own read).
|
|
644
|
+
*/
|
|
645
|
+
export function findDanglingArtifacts(cwd) {
|
|
646
|
+
const prod = discoverProducers(cwd);
|
|
647
|
+
const refs = [];
|
|
648
|
+
for (const rel of scanCandidates(cwd, prod)) {
|
|
649
|
+
let src;
|
|
650
|
+
try {
|
|
651
|
+
src = readFileSync(path.join(cwd, rel), 'utf8');
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
if (SCAN_HTML_RE.test(rel)) {
|
|
657
|
+
refs.push(...extractHtmlRefs(src, rel));
|
|
658
|
+
}
|
|
659
|
+
else {
|
|
660
|
+
refs.push(...extractJsRefs(src, rel));
|
|
661
|
+
collectProducersFromSource(src, prod);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
for (const [name, body] of Object.entries(packageScripts(cwd))) {
|
|
665
|
+
refs.push(...extractScriptEntrypoints(body, `package.json scripts.${name}`));
|
|
666
|
+
}
|
|
667
|
+
return resolveDanglingRefs(refs, prod, rel => existsSync(path.join(cwd, rel)));
|
|
668
|
+
}
|
|
669
|
+
/** Ranked-failure text for the final gate (names referencer + missing path). */
|
|
670
|
+
export function danglingGateFailureText(d) {
|
|
671
|
+
return (`dangling artifact: \`${d.referencer}\` references \`${d.path}\` (${d.construct}) `
|
|
672
|
+
+ `but nothing in the tree, build outputs, or scripts produces it — ${d.reason}`);
|
|
673
|
+
}
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// Plan-time seam: the SPEC's own snippets referencing artifacts the spec never
|
|
676
|
+
// defines (mx5 run 13: DESIGN/PROJECT.md L224 required serving index.html; the
|
|
677
|
+
// file tree (§7) and build section (§9) never defined it).
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
/** Does the spec LIST the file as its own artifact — a file-tree entry or a
|
|
680
|
+
* bullet whose first token is (or ends with) the basename? Prose that merely
|
|
681
|
+
* mentions the name mid-sentence ("serves the built index.html") does NOT
|
|
682
|
+
* count: that is the CONSUMING side, exactly what must not self-satisfy. */
|
|
683
|
+
export function specListsFile(spec, refPath) {
|
|
684
|
+
const base = path.posix.basename(refPath).toLowerCase();
|
|
685
|
+
for (const line of spec.split('\n')) {
|
|
686
|
+
const cleaned = line
|
|
687
|
+
.replace(/[│├└─┬┼]/g, ' ')
|
|
688
|
+
.replace(/^[\s*+-]+/, '')
|
|
689
|
+
.replace(/[`'"]/g, '')
|
|
690
|
+
.trim();
|
|
691
|
+
const tok = (cleaned.split(/\s+/)[0] ?? '').toLowerCase();
|
|
692
|
+
if (tok === base || tok.endsWith('/' + base))
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
/** Consuming-side prose verbs: a spec sentence that SERVES/READS/LOADS a
|
|
698
|
+
* backticked file is referencing it at runtime (mx5 run 13, the exact line:
|
|
699
|
+
* "**SPA fallback:** non-`/api` GETs serve the built `index.html`."). */
|
|
700
|
+
const PROSE_CONSUME_RE = /\b(?:serves?|serving|served|fallback|reads?|loads?|renders?)\b/i;
|
|
701
|
+
/** Runtime-artifact extensions the prose channel accepts. Prose is the loosest
|
|
702
|
+
* signal, so it is whitelist-tight: a backticked dotted identifier (`c.var.user`,
|
|
703
|
+
* `Bun.password.hash` — measured FPs on the real mx5 spec) must never read as a
|
|
704
|
+
* file, and doc files a spec tells the READER to read (`README.md`) don't
|
|
705
|
+
* count either. Code-construct refs are not subject to this list. */
|
|
706
|
+
const PROSE_ASSET_EXT_RE = /\.(?:html?|css|m?js|cjs|json|svg|png|jpe?g|gif|webp|ico|woff2?|ttf|otf|wasm|webmanifest|xml|csv|sql|ya?ml|toml|pdf|mp[34]|db|sqlite)$/i;
|
|
707
|
+
/** Backticked, asset-extension, path-shaped tokens on consuming-verb lines. */
|
|
708
|
+
export function extractSpecProseRefs(spec) {
|
|
709
|
+
const out = [];
|
|
710
|
+
for (const line of spec.split('\n')) {
|
|
711
|
+
if (!PROSE_CONSUME_RE.test(line))
|
|
712
|
+
continue;
|
|
713
|
+
const tick = /`([^`\n]+)`/g;
|
|
714
|
+
for (let m = tick.exec(line); m !== null; m = tick.exec(line)) {
|
|
715
|
+
const tok = m[1];
|
|
716
|
+
if (!/^[\w./-]+$/.test(tok))
|
|
717
|
+
continue; // code fragments, not paths
|
|
718
|
+
const p = normalizeRefPath(tok);
|
|
719
|
+
if (p === null || !PROSE_ASSET_EXT_RE.test(p))
|
|
720
|
+
continue;
|
|
721
|
+
out.push({
|
|
722
|
+
path: p,
|
|
723
|
+
referencer: 'spec',
|
|
724
|
+
construct: 'spec prose (serve/read)',
|
|
725
|
+
kind: 'file'
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return out;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* PLAN-TIME seam: runtime refs in the spec's snippets AND consuming prose that
|
|
733
|
+
* neither the existing scaffold (`fileExists`), the spec's parsed build
|
|
734
|
+
* outputs, nor its own file tree produce. Each result becomes an UNOWNED
|
|
735
|
+
* coverage area until some task title claims the artifact.
|
|
736
|
+
*/
|
|
737
|
+
export function findSpecDanglingArtifacts(spec, fileExists) {
|
|
738
|
+
const prod = emptyProducers();
|
|
739
|
+
collectProducersFromSource(spec, prod);
|
|
740
|
+
// Shell-looking lines in the spec (build/script snippets) contribute
|
|
741
|
+
// producers too: only lines carrying an output-flag shape, and never
|
|
742
|
+
// markdown blockquotes (`> …` is quoting, not a shell redirect), so prose
|
|
743
|
+
// cannot feed the producer table and mask a real dangle.
|
|
744
|
+
for (const line of spec.split('\n')) {
|
|
745
|
+
if (line.trimStart().startsWith('>'))
|
|
746
|
+
continue;
|
|
747
|
+
if (/(?:^|\s)(?:--out(?:file|put|dir|-dir)?|-o)(?:=|\s)/.test(line)) {
|
|
748
|
+
for (const cmd of splitShellCommands(line)) {
|
|
749
|
+
collectProducersFromCommand(cmd, prod, { escalate: false });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// package.json-snippet script lines (`"build": "bun build.ts"`) — parse the
|
|
754
|
+
// body for producers.
|
|
755
|
+
const scriptLineRe = /"([A-Za-z0-9:_-]+)"\s*:\s*"([^"\n]+)"/g;
|
|
756
|
+
for (let m = scriptLineRe.exec(spec); m !== null; m = scriptLineRe.exec(spec)) {
|
|
757
|
+
const body = m[2];
|
|
758
|
+
if (/^(?:bun|bunx|node|tsx|npm|npx|deno)\b|--out|-o\s/.test(body)) {
|
|
759
|
+
for (const cmd of splitShellCommands(body))
|
|
760
|
+
collectProducersFromCommand(cmd, prod);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
// Code-construct refs resolve exactly like tree refs.
|
|
764
|
+
const codeRefs = [
|
|
765
|
+
...extractJsRefs(spec, 'spec'),
|
|
766
|
+
...extractHtmlRefs(spec, 'spec')
|
|
767
|
+
];
|
|
768
|
+
const dangling = resolveDanglingRefs(codeRefs, prod, fileExists);
|
|
769
|
+
// Prose refs carry no directory, so they resolve by BASENAME against the
|
|
770
|
+
// declared outputs — and dangle ONLY when the spec positively declares an
|
|
771
|
+
// enumerable output set that does not include them (a spec with no
|
|
772
|
+
// parseable build machinery, or any opaque producer, yields no verdict —
|
|
773
|
+
// inconclusive is never evidence).
|
|
774
|
+
const declaredStemCount = [...prod.enumerable.values()].reduce((n, s) => n + s.size, 0);
|
|
775
|
+
const hasOutputEvidence = prod.files.size > 0 || declaredStemCount > 0;
|
|
776
|
+
const producedBasenames = new Set([...prod.files].map(f => path.posix.basename(f)));
|
|
777
|
+
const producedStems = new Set([
|
|
778
|
+
...[...prod.files].map(stem),
|
|
779
|
+
...[...prod.enumerable.values()].flatMap(s => [...s])
|
|
780
|
+
]);
|
|
781
|
+
const seen = new Set(dangling.map(d => path.posix.basename(d.path)));
|
|
782
|
+
for (const ref of extractSpecProseRefs(spec)) {
|
|
783
|
+
const base = path.posix.basename(ref.path);
|
|
784
|
+
if (seen.has(base))
|
|
785
|
+
continue;
|
|
786
|
+
if (!hasOutputEvidence || prod.opaque.size > 0)
|
|
787
|
+
continue;
|
|
788
|
+
const satisfied = fileExists(ref.path)
|
|
789
|
+
|| [...prod.dirs, ...prod.enumerable.keys()].some(d => fileExists(`${d}/${ref.path}`))
|
|
790
|
+
|| producedBasenames.has(base)
|
|
791
|
+
|| producedStems.has(stem(ref.path));
|
|
792
|
+
if (satisfied)
|
|
793
|
+
continue;
|
|
794
|
+
seen.add(base);
|
|
795
|
+
dangling.push({
|
|
796
|
+
...ref,
|
|
797
|
+
reason: 'the spec declares its build outputs and none of them is this file'
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
// The spec's own file tree / artifact bullets are plan-time producers: a
|
|
801
|
+
// listed file is an artifact some task will create.
|
|
802
|
+
return dangling.filter(d => !specListsFile(spec, d.path));
|
|
803
|
+
}
|
|
804
|
+
/** Does some task title claim the artifact (by basename or full path)? Titles
|
|
805
|
+
* are the one plan artifact the model cannot fake ownership INTO — mentioning
|
|
806
|
+
* the file is the grounded signal a producing task exists. */
|
|
807
|
+
export function titlesCoverArtifact(titles, ref) {
|
|
808
|
+
const base = path.posix.basename(ref.path).toLowerCase();
|
|
809
|
+
const full = ref.path.toLowerCase();
|
|
810
|
+
return titles.some(t => {
|
|
811
|
+
const tl = t.toLowerCase();
|
|
812
|
+
return tl.includes(base) || tl.includes(full);
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
/** Coverage-loop `missing` entry for an unowned dangling artifact. */
|
|
816
|
+
export function danglingMissingText(d) {
|
|
817
|
+
return (`dangling runtime artifact \`${d.path}\` — referenced by the spec (${d.construct}) `
|
|
818
|
+
+ `but no file tree entry, build output, or task produces it; add a task that `
|
|
819
|
+
+ `creates it or makes the build emit it`);
|
|
820
|
+
}
|
|
821
|
+
/** Carried-requirement line when still unowned at coverage exhaustion. */
|
|
822
|
+
export function danglingCarryText(d) {
|
|
823
|
+
return (`runtime artifact \`${d.path}\` is referenced (${d.construct}) but NOTHING creates it — `
|
|
824
|
+
+ `whichever task builds the referencing side must also produce this file or wire the `
|
|
825
|
+
+ `build to emit it`);
|
|
826
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* This module currently holds the planning half (AutoDeps + planAuto). The run
|
|
6
6
|
* loop, command handlers, and defaultDeps are added by the next task.
|
|
7
7
|
*/
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
8
9
|
import * as fsp from 'node:fs/promises';
|
|
9
10
|
import * as path from 'node:path';
|
|
10
11
|
import { gateRunTask, markResumable } from './orchestrator.js';
|
|
@@ -35,6 +36,7 @@ import { CONTRACT_EXTRACT_PROMPT, parseContractLines, keepGroundedContracts, app
|
|
|
35
36
|
import { reconcileTitleSources } from './decompose-fidelity.js';
|
|
36
37
|
import { REQUIREMENT_EXTRACT_PROMPT, COVERAGE_MAP_PROMPT, parseRequirementLines, keepGroundedRequirements, capRequirements, enumerateObligationPassages, uncoveredPassages, extractionRetryHint, parseCoverageMap, accountCoverage, isCrossCuttingRequirement, appendCarriedRequirements, buildRequirementsLedger } from './requirements.js';
|
|
37
38
|
import { decideAdoption, groundedCoverage } from './coverage-loop.js';
|
|
39
|
+
import { findSpecDanglingArtifacts, titlesCoverArtifact, danglingMissingText, danglingCarryText } from './artifact-closure.js';
|
|
38
40
|
import { LAUNCH_EXTRACT_PROMPT, enumerateScriptCandidates, parseScriptLines, keepGroundedScripts, appendDeclaredScripts } from './launch-contract.js';
|
|
39
41
|
// Hard ceiling on clarify questions per feature. The loop is open-ended (it stops
|
|
40
42
|
// when the model emits NONE), but a model that never says NONE would otherwise
|
|
@@ -493,6 +495,25 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
493
495
|
catch {
|
|
494
496
|
// best-effort channel
|
|
495
497
|
}
|
|
498
|
+
// Artifact-production closure, plan side (mx5 run 13, PROMPT 2): runtime
|
|
499
|
+
// files the spec REFERENCES (server snippets, prose "serve the built
|
|
500
|
+
// index.html") that neither its file tree, its parsed build outputs, nor the
|
|
501
|
+
// existing scaffold produce. Sentence-grounded coverage credited the SERVING
|
|
502
|
+
// side and reported "0 unowned" while nothing ever CREATED the file — so
|
|
503
|
+
// these ride the coverage loop's `missing` list as unowned areas until some
|
|
504
|
+
// task title claims the artifact (grounded in titles, which the coverage-map
|
|
505
|
+
// model cannot fake — the run-12 lesson). Deterministic and best-effort.
|
|
506
|
+
let specDangling = [];
|
|
507
|
+
try {
|
|
508
|
+
specDangling = findSpecDanglingArtifacts(featureForModel, rel => existsSync(path.join(cwd, rel)));
|
|
509
|
+
if (specDangling.length > 0) {
|
|
510
|
+
logPlanDebug(cwd, `artifact closure: ${specDangling.length} dangling runtime artifact(s) in the `
|
|
511
|
+
+ `spec: ${specDangling.map(d => d.path).join(', ')}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// best-effort channel
|
|
516
|
+
}
|
|
496
517
|
// decompose
|
|
497
518
|
const decomposePrompt = AUTO_DECOMPOSE_PROMPT(featureForModel, clarifications, buildRequirementsLedger(reqEntries));
|
|
498
519
|
// Parse + FIDELITY RECONCILIATION (mx5 run 11, goal B): ground each title's
|
|
@@ -589,6 +610,13 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
589
610
|
}
|
|
590
611
|
}
|
|
591
612
|
const missing = [...verdictMissing, ...(acc?.unmapped ?? []).map(e => `"${e.quote}"`)];
|
|
613
|
+
// Unclaimed dangling artifacts are unowned areas: they force a coverage
|
|
614
|
+
// round that assigns a producing task, and clear as soon as a title
|
|
615
|
+
// names the file.
|
|
616
|
+
for (const d of specDangling) {
|
|
617
|
+
if (!titlesCoverArtifact(titles, d))
|
|
618
|
+
missing.push(danglingMissingText(d));
|
|
619
|
+
}
|
|
592
620
|
return {
|
|
593
621
|
plan: { titles, covered, missing },
|
|
594
622
|
accounting: acc,
|
|
@@ -707,12 +735,23 @@ export async function planAuto(ctx, cwd, feature, deps) {
|
|
|
707
735
|
const carriedCrossCutting = accounting?.crossCutting ?? [];
|
|
708
736
|
const carriedUnmapped = accounting?.unmapped ?? [];
|
|
709
737
|
const carriedJudge = best.judgeMissing;
|
|
710
|
-
|
|
711
|
-
|
|
738
|
+
// Dangling artifacts still unclaimed by any title of the SHIPPING plan are a
|
|
739
|
+
// fourth channel: the producing obligation travels verbatim into every task
|
|
740
|
+
// (whichever task builds the referencing side must also produce the file),
|
|
741
|
+
// and the final gate re-checks the shipped tree regardless.
|
|
742
|
+
const carriedDangling = specDangling
|
|
743
|
+
.filter(d => !titlesCoverArtifact(planTitles, d))
|
|
744
|
+
.map(danglingCarryText);
|
|
745
|
+
if (carriedCrossCutting.length > 0
|
|
746
|
+
|| carriedUnmapped.length > 0
|
|
747
|
+
|| carriedJudge.length > 0
|
|
748
|
+
|| carriedDangling.length > 0) {
|
|
749
|
+
await appendCarriedRequirements(cwd, carriedCrossCutting, carriedUnmapped, carriedJudge, carriedDangling);
|
|
712
750
|
const parts = [
|
|
713
751
|
carriedCrossCutting.length > 0 ? `${carriedCrossCutting.length} cross-cutting` : '',
|
|
714
752
|
carriedUnmapped.length > 0 ? `${carriedUnmapped.length} unowned` : '',
|
|
715
|
-
carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : ''
|
|
753
|
+
carriedJudge.length > 0 ? `${carriedJudge.length} judge-flagged` : '',
|
|
754
|
+
carriedDangling.length > 0 ? `${carriedDangling.length} dangling-artifact` : ''
|
|
716
755
|
].filter(p => p.length > 0);
|
|
717
756
|
ctx.ui.notify(`/task-auto: carrying ${parts.join(', ')} requirement(s) into every task`
|
|
718
757
|
+ ' — see .pi-tasks/requirements.md.', 'info');
|
package/dist/task/final-gate.js
CHANGED
|
@@ -47,6 +47,7 @@ import { readDeclaredScripts, missingDeclaredScripts, runnableDeclaredScripts }
|
|
|
47
47
|
import { readEnvNotes, parseEnvNotes, isExcuseNote } from './env-notes.js';
|
|
48
48
|
import { runRenderCheck } from './render-check.js';
|
|
49
49
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
50
|
+
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
50
51
|
function packageScripts(cwd) {
|
|
51
52
|
try {
|
|
52
53
|
const j = JSON.parse(readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
@@ -772,6 +773,21 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
772
773
|
warnings.push(b.renderNote);
|
|
773
774
|
}
|
|
774
775
|
}
|
|
776
|
+
// Artifact-production closure (mx5 run 13, PROMPT 2): a runtime file
|
|
777
|
+
// reference with NO producer anywhere ships silently — the server read
|
|
778
|
+
// `Bun.file('dist/index.html')` while the build emitted only app.css +
|
|
779
|
+
// main.js, so every non-API GET 404'd behind 32/32 green checkoffs.
|
|
780
|
+
// Deterministic scan of the shipped tree (literal refs only, positive
|
|
781
|
+
// producer evidence required — see artifact-closure.ts); each dangle is a
|
|
782
|
+
// ranked failure naming referencer + missing path. Rank 0: "the app cannot
|
|
783
|
+
// serve what it references" is the same load-bearing class as boot/render.
|
|
784
|
+
try {
|
|
785
|
+
for (const d of findDanglingArtifacts(cwd))
|
|
786
|
+
fail(danglingGateFailureText(d), 0);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
// best-effort scan — a scanner fault must never break the gate
|
|
790
|
+
}
|
|
775
791
|
if (failures.length > 0) {
|
|
776
792
|
// Stable sort: boot/render (rank 0) leads, everything else keeps execution
|
|
777
793
|
// order. One failure keeps the exact single-failure wording; several become
|
|
@@ -88,8 +88,13 @@ export declare function readRequirements(cwd: string): Promise<string>;
|
|
|
88
88
|
* was warned-about then dropped). These are plain strings, not quotes of the
|
|
89
89
|
* source; marked distinctly so a task can tell an inferred area from a verbatim
|
|
90
90
|
* obligation.
|
|
91
|
+
* • `danglingArtifacts` — runtime files the spec references but nothing
|
|
92
|
+
* produces (mx5 run 13: the served `index.html` no task, tree entry, or
|
|
93
|
+
* build output ever created), still unclaimed by any title at coverage
|
|
94
|
+
* exhaustion. Deterministically extracted (artifact-closure.ts), so like
|
|
95
|
+
* judge areas they are host-authored strings, not source quotes.
|
|
91
96
|
*/
|
|
92
|
-
export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[]): Promise<void>;
|
|
97
|
+
export declare function appendCarriedRequirements(cwd: string, crossCutting: RequirementEntry[], unresolved?: RequirementEntry[], judgeFlagged?: string[], danglingArtifacts?: string[]): Promise<void>;
|
|
93
98
|
/**
|
|
94
99
|
* The read-only block refine/compose receive when carried requirements exist.
|
|
95
100
|
* Verbatim content travels with every task (the directive pattern that works),
|
|
@@ -320,10 +320,19 @@ function formatEntry(e, marker) {
|
|
|
320
320
|
* was warned-about then dropped). These are plain strings, not quotes of the
|
|
321
321
|
* source; marked distinctly so a task can tell an inferred area from a verbatim
|
|
322
322
|
* obligation.
|
|
323
|
+
* • `danglingArtifacts` — runtime files the spec references but nothing
|
|
324
|
+
* produces (mx5 run 13: the served `index.html` no task, tree entry, or
|
|
325
|
+
* build output ever created), still unclaimed by any title at coverage
|
|
326
|
+
* exhaustion. Deterministically extracted (artifact-closure.ts), so like
|
|
327
|
+
* judge areas they are host-authored strings, not source quotes.
|
|
323
328
|
*/
|
|
324
|
-
export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = []) {
|
|
325
|
-
if (crossCutting.length === 0
|
|
329
|
+
export async function appendCarriedRequirements(cwd, crossCutting, unresolved = [], judgeFlagged = [], danglingArtifacts = []) {
|
|
330
|
+
if (crossCutting.length === 0
|
|
331
|
+
&& unresolved.length === 0
|
|
332
|
+
&& judgeFlagged.length === 0
|
|
333
|
+
&& danglingArtifacts.length === 0) {
|
|
326
334
|
return;
|
|
335
|
+
}
|
|
327
336
|
try {
|
|
328
337
|
const existing = (await readRequirements(cwd)).split('\n').filter(l => l.trim().length > 0);
|
|
329
338
|
const seen = new Set(existing.map(l => {
|
|
@@ -337,6 +346,10 @@ export async function appendCarriedRequirements(cwd, crossCutting, unresolved =
|
|
|
337
346
|
[
|
|
338
347
|
judgeFlagged.map(q => ({ quote: q, anchor: '' })),
|
|
339
348
|
'judge-flagged uncovered area, no task owns this — surfaced at plan time'
|
|
349
|
+
],
|
|
350
|
+
[
|
|
351
|
+
danglingArtifacts.map(q => ({ quote: q, anchor: '' })),
|
|
352
|
+
'dangling runtime artifact, nothing produces it — surfaced at plan time'
|
|
340
353
|
]
|
|
341
354
|
]) {
|
|
342
355
|
for (const e of entries) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.25",
|
|
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",
|