@mjasnikovs/pi-task 0.34.0 → 0.35.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.
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Variables the PLATFORM supplies, not the project. Deliberately short: every
|
|
2
|
+
* entry is a name no `.env.example` is expected to carry. */
|
|
3
|
+
export declare const AMBIENT_ENV: Set<string>;
|
|
4
|
+
/** The same rule by MECHANISM rather than by name: a variable INJECTED by a
|
|
5
|
+
* platform (CI runner, package manager, freedesktop base dirs). STEP 0 found
|
|
6
|
+
* `GITHUB_ENV` (written by GitHub Actions), `npm_execpath` and `XDG_CACHE_HOME`
|
|
7
|
+
* classified as required in real trees; a template that declared them would be
|
|
8
|
+
* wrong, so the exclusion is the prefix, not a growing list of names. */
|
|
9
|
+
export declare const AMBIENT_PREFIXES: string[];
|
|
10
|
+
/** Rule 4: is this variable the platform's to supply? */
|
|
11
|
+
export declare function isAmbient(name: string): boolean;
|
|
12
|
+
/** Basenames that count as the project's env TEMPLATE. */
|
|
13
|
+
export declare const ENV_TEMPLATE_NAMES: string[];
|
|
14
|
+
/** Why a read does not require a declaration. `null` ⇒ it does. */
|
|
15
|
+
export type StepAside = 'default' | 'compared' | 'assigned' | 'ambient' | 'optional-api' | 'probe';
|
|
16
|
+
export interface EnvRead {
|
|
17
|
+
/** Variable name, always a literal. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Repo-relative file the read lives in. */
|
|
20
|
+
file: string;
|
|
21
|
+
/** 1-indexed line of the read. */
|
|
22
|
+
line: number;
|
|
23
|
+
/** The matched construct (`process.env`, `os.getenv`, …). */
|
|
24
|
+
construct: string;
|
|
25
|
+
/** null ⇒ REQUIRED; otherwise the mechanical reason it stepped aside. */
|
|
26
|
+
stepAside: StepAside | null;
|
|
27
|
+
}
|
|
28
|
+
export interface EnvClosure {
|
|
29
|
+
/** Tracked template files found (repo-relative). Empty ⇒ check is inert. */
|
|
30
|
+
templates: string[];
|
|
31
|
+
/** Every variable any template declares. */
|
|
32
|
+
declared: Set<string>;
|
|
33
|
+
/** Every literal env read seen in tracked source, step-asides included. */
|
|
34
|
+
reads: EnvRead[];
|
|
35
|
+
/** Required reads whose variable no template declares — the findings. */
|
|
36
|
+
missing: EnvRead[];
|
|
37
|
+
}
|
|
38
|
+
/** An inert result: what every tree with no tracked template returns. */
|
|
39
|
+
export declare function inertClosure(): EnvClosure;
|
|
40
|
+
/** Tracked files, repo-root-relative. null when `cwd` is not a git work tree. */
|
|
41
|
+
export declare function trackedFiles(cwd: string): string[] | null;
|
|
42
|
+
/** Variables a template file declares. A bare `X=` line declares `X`. */
|
|
43
|
+
export declare function parseTemplate(text: string): string[];
|
|
44
|
+
/** Every literal env read in one file's text. */
|
|
45
|
+
export declare function scanSource(file: string, text: string): EnvRead[];
|
|
46
|
+
/**
|
|
47
|
+
* Scan a tree: tracked templates × required reads in tracked source.
|
|
48
|
+
*
|
|
49
|
+
* Inert (empty templates, no findings) when the tree is not a git work tree or
|
|
50
|
+
* carries no tracked env template — the ENOENT=pass contract.
|
|
51
|
+
*/
|
|
52
|
+
export declare function scanEnvTemplateClosure(cwd: string): EnvClosure;
|
|
53
|
+
/**
|
|
54
|
+
* The gate's view: one finding per VARIABLE (first read site, source order), not
|
|
55
|
+
* one per read — a var read in four files is one artifact defect, and the ranked
|
|
56
|
+
* failure list is read by a human and seeds the autofix prompt.
|
|
57
|
+
*/
|
|
58
|
+
export declare function findMissingEnvDeclarations(cwd: string): {
|
|
59
|
+
templates: string[];
|
|
60
|
+
missing: EnvRead[];
|
|
61
|
+
};
|
|
62
|
+
/** One finding, as the final gate would phrase it. */
|
|
63
|
+
export declare function envGateFailureText(t: EnvRead, templates: string[]): string;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* env-template-closure — a shipped source file requires an env var the shipped
|
|
3
|
+
* template never mentions (nexttask 10).
|
|
4
|
+
*
|
|
5
|
+
* The failure this closes (mx5 run 19, `dfbdd6f`, validated): `src/server/seed.ts`
|
|
6
|
+
* reads `process.env.ADMIN_PHONE` / `ADMIN_PASSWORD` with no default, the tracked
|
|
7
|
+
* `.env.example` declares only `DATABASE_URL` + `APP_URL`, and `bun run seed` was
|
|
8
|
+
* one of the run's final-gate commands. It exited 1; the gate's autofix wrote the
|
|
9
|
+
* two vars into `.env`, which is GITIGNORED (nexttask 4) — so the committed tree
|
|
10
|
+
* still cannot seed and nothing at run end ever said why. Coverage credits the
|
|
11
|
+
* CONSUMING side (seed.ts exists, is owned, passes its own VERIFY); the template is
|
|
12
|
+
* a separate artifact nobody re-reads after TASK_0001.
|
|
13
|
+
*
|
|
14
|
+
* THE RULE. Every REQUIRED env read in TRACKED source must appear in the TRACKED
|
|
15
|
+
* env template. One-directional: a variable DECLARED but never read is silence, not
|
|
16
|
+
* a finding (dace-pro ships 13 declared / 7 required and must stay clean).
|
|
17
|
+
*
|
|
18
|
+
* REQUIRED is mechanical. A read steps aside when it:
|
|
19
|
+
* 1. carries a default — `?? …`, `|| …`, `os.getenv('X', d)`, `environ.get('X', d)`;
|
|
20
|
+
* 2. is compared, not consumed — `===`, `!==`, `==`, `!=` on either side;
|
|
21
|
+
* 3. is an assignment TARGET — `process.env.DATABASE_URL = testDbUrl`, which is
|
|
22
|
+
* what every one of mx5's seven test files does and is the single largest FP
|
|
23
|
+
* source in the corpus;
|
|
24
|
+
* 4. names an AMBIENT variable — a short fixed allowlist (below) of things the
|
|
25
|
+
* platform, not the project, supplies.
|
|
26
|
+
*
|
|
27
|
+
* FP discipline (the artifact-closure discipline, one layer up):
|
|
28
|
+
* • literal names only. `process.env[key]`, `const {A} = process.env` and every
|
|
29
|
+
* other dynamic form is OPAQUE and steps aside — a false negative is free, a
|
|
30
|
+
* false rank-0 gate failure is not.
|
|
31
|
+
* • TRACKED files only, via `git ls-files`, so a gitignored `.env` or an
|
|
32
|
+
* untracked scratch file can neither declare nor require anything.
|
|
33
|
+
* • generated/vendored trees (`dist/`, `build/`, `node_modules/`, …) are not
|
|
34
|
+
* authored source and are skipped; they only ever mirror a read we already saw.
|
|
35
|
+
* • NO TEMPLATE IN THE TREE ⇒ THE CHECK IS INERT (as `repo-health-check` does
|
|
36
|
+
* with a missing manifest). This must never invent a file the project chose
|
|
37
|
+
* not to have.
|
|
38
|
+
*
|
|
39
|
+
* Not npm-shaped (`memory/gate-blind-on-non-npm-projects.md`): JS/TS
|
|
40
|
+
* (`process.env` / `Bun.env`), Python (`os.environ[…]`, `os.environ.get`,
|
|
41
|
+
* `os.getenv`) and Go (`os.Getenv`) read the same way. Go's `os.LookupEnv` is the
|
|
42
|
+
* explicit may-be-absent API and is treated as its own step-aside.
|
|
43
|
+
*/
|
|
44
|
+
import { spawnSync } from 'node:child_process';
|
|
45
|
+
import { readFileSync } from 'node:fs';
|
|
46
|
+
import * as path from 'node:path';
|
|
47
|
+
/** Variables the PLATFORM supplies, not the project. Deliberately short: every
|
|
48
|
+
* entry is a name no `.env.example` is expected to carry. */
|
|
49
|
+
export const AMBIENT_ENV = new Set([
|
|
50
|
+
'NODE_ENV',
|
|
51
|
+
'CI',
|
|
52
|
+
'PATH',
|
|
53
|
+
'HOME',
|
|
54
|
+
'TZ',
|
|
55
|
+
'PORT',
|
|
56
|
+
'USER',
|
|
57
|
+
'SHELL',
|
|
58
|
+
'TMPDIR',
|
|
59
|
+
'LANG',
|
|
60
|
+
// Windows/OS equivalents of the above, and the package manager's own vars —
|
|
61
|
+
// every one of these was OBSERVED as a "required" read during STEP 0's
|
|
62
|
+
// hand-read (gofer-rag `npm_execpath`, the pi-task corpus's `LOCALAPPDATA` /
|
|
63
|
+
// `XDG_CACHE_HOME`), and none of them belongs in a project's template.
|
|
64
|
+
'APPDATA',
|
|
65
|
+
'LOCALAPPDATA',
|
|
66
|
+
'USERPROFILE',
|
|
67
|
+
'TEMP',
|
|
68
|
+
'TMP',
|
|
69
|
+
'PWD',
|
|
70
|
+
'HOSTNAME',
|
|
71
|
+
'LOGNAME',
|
|
72
|
+
'TERM',
|
|
73
|
+
'COMSPEC'
|
|
74
|
+
]);
|
|
75
|
+
/** The same rule by MECHANISM rather than by name: a variable INJECTED by a
|
|
76
|
+
* platform (CI runner, package manager, freedesktop base dirs). STEP 0 found
|
|
77
|
+
* `GITHUB_ENV` (written by GitHub Actions), `npm_execpath` and `XDG_CACHE_HOME`
|
|
78
|
+
* classified as required in real trees; a template that declared them would be
|
|
79
|
+
* wrong, so the exclusion is the prefix, not a growing list of names. */
|
|
80
|
+
export const AMBIENT_PREFIXES = ['npm_', 'GITHUB_', 'RUNNER_', 'CI_', 'XDG_', 'BUN_', 'NODE_'];
|
|
81
|
+
/** Rule 4: is this variable the platform's to supply? */
|
|
82
|
+
export function isAmbient(name) {
|
|
83
|
+
return AMBIENT_ENV.has(name) || AMBIENT_PREFIXES.some(p => name.startsWith(p));
|
|
84
|
+
}
|
|
85
|
+
/** Basenames that count as the project's env TEMPLATE. */
|
|
86
|
+
export const ENV_TEMPLATE_NAMES = ['.env.example', '.env.sample', '.env.template', '.env.dist'];
|
|
87
|
+
/** Source extensions we can read env accesses out of. */
|
|
88
|
+
const SOURCE_EXT_RE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go)$/i;
|
|
89
|
+
/** Generated / vendored trees — not authored source. */
|
|
90
|
+
const GENERATED_RE = /(?:^|\/)(?:node_modules|dist|build|out|vendor|coverage|\.next|\.output)\//;
|
|
91
|
+
/** An inert result: what every tree with no tracked template returns. */
|
|
92
|
+
export function inertClosure() {
|
|
93
|
+
return { templates: [], declared: new Set(), reads: [], missing: [] };
|
|
94
|
+
}
|
|
95
|
+
function git(cwd, args) {
|
|
96
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
97
|
+
if (r.error || r.status !== 0 || typeof r.stdout !== 'string')
|
|
98
|
+
return null;
|
|
99
|
+
return r.stdout;
|
|
100
|
+
}
|
|
101
|
+
/** Tracked files, repo-root-relative. null when `cwd` is not a git work tree. */
|
|
102
|
+
export function trackedFiles(cwd) {
|
|
103
|
+
const out = git(cwd, ['ls-files', '-z']);
|
|
104
|
+
if (out === null)
|
|
105
|
+
return null;
|
|
106
|
+
return out.split('\0').filter(p => p.length > 0);
|
|
107
|
+
}
|
|
108
|
+
/** Variables a template file declares. A bare `X=` line declares `X`. */
|
|
109
|
+
export function parseTemplate(text) {
|
|
110
|
+
const names = [];
|
|
111
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
112
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(raw);
|
|
113
|
+
if (m)
|
|
114
|
+
names.push(m[1]);
|
|
115
|
+
}
|
|
116
|
+
return names;
|
|
117
|
+
}
|
|
118
|
+
/** Strip line comments so a read quoted in prose is not a read. Conservative:
|
|
119
|
+
* only whole-line `//`, `#`, `*` and `/*` openers — never mid-line, because a
|
|
120
|
+
* `//` inside a string literal (a URL) would eat real code. */
|
|
121
|
+
function isCommentLine(line) {
|
|
122
|
+
return /^\s*(?:\/\/|#|\*|\/\*)/.test(line);
|
|
123
|
+
}
|
|
124
|
+
/** A trailing `, default` inside the call — `os.getenv('X', 'y')`. */
|
|
125
|
+
const callHasDefault = (after) => /^\s*,/.test(after);
|
|
126
|
+
const MATCHERS = [
|
|
127
|
+
// JS/TS — dotted and bracketed.
|
|
128
|
+
{ re: /\b(?:process|Bun)\.env\s*\.\s*([A-Za-z_][A-Za-z0-9_]*)/g, construct: 'process.env' },
|
|
129
|
+
{
|
|
130
|
+
re: /\b(?:process|Bun)\.env\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]/g,
|
|
131
|
+
construct: 'process.env[]'
|
|
132
|
+
},
|
|
133
|
+
// Python.
|
|
134
|
+
{
|
|
135
|
+
re: /\bos\.environ\s*\[\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]\s*\]/g,
|
|
136
|
+
construct: 'os.environ[]'
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
re: /\bos\.environ\.get\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g,
|
|
140
|
+
construct: 'os.environ.get',
|
|
141
|
+
call: true,
|
|
142
|
+
classify: (_m, after) => (callHasDefault(after) ? 'default' : null)
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
re: /\bos\.getenv\s*\(\s*['"]([A-Za-z_][A-Za-z0-9_]*)['"]/g,
|
|
146
|
+
construct: 'os.getenv',
|
|
147
|
+
call: true,
|
|
148
|
+
classify: (_m, after) => (callHasDefault(after) ? 'default' : null)
|
|
149
|
+
},
|
|
150
|
+
// Go.
|
|
151
|
+
{ re: /\bos\.Getenv\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"/g, construct: 'os.Getenv', call: true },
|
|
152
|
+
{
|
|
153
|
+
re: /\bos\.LookupEnv\s*\(\s*"([A-Za-z_][A-Za-z0-9_]*)"/g,
|
|
154
|
+
construct: 'os.LookupEnv',
|
|
155
|
+
call: true,
|
|
156
|
+
classify: () => 'optional-api'
|
|
157
|
+
}
|
|
158
|
+
];
|
|
159
|
+
/** Does the text right after the read supply a default, or continue onto a line
|
|
160
|
+
* that opens with one? (`process.env.X\n ?? 'y'` is one expression.)
|
|
161
|
+
*
|
|
162
|
+
* A ternary CONDITION counts: `process.env.npm_execpath ? a : b` names both
|
|
163
|
+
* outcomes, so nothing is required (gofer-rag, hand-read in STEP 0). `?.` is
|
|
164
|
+
* optional chaining, not a ternary, and must not match. */
|
|
165
|
+
function hasDefault(before, after, nextLine) {
|
|
166
|
+
// The read IS the fallback: `X ?? process.env.BRAVE_API_KEY` (brave-warning.ts)
|
|
167
|
+
// — an alternative to another source, never independently required.
|
|
168
|
+
if (/(?:\?\?|\|\|)\s*$/.test(before))
|
|
169
|
+
return true;
|
|
170
|
+
if (/^\s*(?:\?\?|\|\||\?(?![.?]))/.test(after))
|
|
171
|
+
return true;
|
|
172
|
+
if (after.trim().length === 0 && nextLine !== undefined && /^\s*(?:\?\?|\|\|)/.test(nextLine))
|
|
173
|
+
return true;
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Rule 5 — a PRESENCE PROBE: the read asks whether the variable is set and the
|
|
178
|
+
* code carries on either way. Demanded by the STEP-0 hand-read, which found three
|
|
179
|
+
* shapes of it in real trees and NONE of them belongs in a template:
|
|
180
|
+
*
|
|
181
|
+
* `if (process.env.PI_BIN) {…}` pi-invocation.ts — a test override
|
|
182
|
+
* `const c = process.env.CHROME_BIN` render-check.ts — falls back to the
|
|
183
|
+
* `if (c) …` Playwright cache when unset
|
|
184
|
+
*
|
|
185
|
+
* The discriminator is the SIGN of the guard, and it is exactly what separates
|
|
186
|
+
* those from the lead: mx5's seed.ts writes `const phone = process.env.ADMIN_PHONE`
|
|
187
|
+
* … `if (!phone) throw`. A NEGATED guard means the variable is required and the
|
|
188
|
+
* program stops without it; a POSITIVE guard means it is an override. So a bare
|
|
189
|
+
* `if (` / `while (` head steps aside, `if (!` does not, and an assign-then-guard
|
|
190
|
+
* is resolved by looking ahead for the FIRST guard on that variable.
|
|
191
|
+
*/
|
|
192
|
+
const GUARD_HEAD_RE = /\b(?:if|while)\s*\(\s*$/;
|
|
193
|
+
/** How far an assign-then-guard may reach. mx5's is 4 lines; 10 is slack. */
|
|
194
|
+
const GUARD_LOOKAHEAD = 10;
|
|
195
|
+
function isProbeHead(before) {
|
|
196
|
+
return GUARD_HEAD_RE.test(before);
|
|
197
|
+
}
|
|
198
|
+
/** `const v = process.env.X` → the variable's name, else null. */
|
|
199
|
+
function assignedTo(before) {
|
|
200
|
+
const m = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*$/.exec(before);
|
|
201
|
+
return m ? m[1] : null;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* What the guard's own body does. A NEGATED guard only means "required" when the
|
|
205
|
+
* program STOPS without the variable — `if (!phone) throw` (mx5, the lead). When
|
|
206
|
+
* it merely returns or falls through — `if (!process.env.PI_REMOTE_PUSH_DEBUG)
|
|
207
|
+
* return` (push.ts, a debug flag) — the variable is optional and the negation
|
|
208
|
+
* proves nothing. Same syntax, opposite meaning; the body is the discriminator.
|
|
209
|
+
*/
|
|
210
|
+
const HARD_STOP_RE = /\bthrow\b|process\.exit\s*\(|\bos\.Exit\s*\(|\bsys\.exit\s*\(|\bpanic\s*\(|\braise\b/;
|
|
211
|
+
const HARD_STOP_LOOKAHEAD = 3;
|
|
212
|
+
function bodyStops(lines, at) {
|
|
213
|
+
for (let i = at; i < Math.min(lines.length, at + HARD_STOP_LOOKAHEAD); i++) {
|
|
214
|
+
if (HARD_STOP_RE.test(lines[i]))
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
/** First `if (v)` / `if (!v)` on `name` within the window. `required` only for a
|
|
220
|
+
* negated guard that stops; anything else is an override probe. */
|
|
221
|
+
function guardVerdict(lines, from, name) {
|
|
222
|
+
const re = new RegExp(`\\bif\\s*\\(\\s*(!?)\\s*${name}\\b`);
|
|
223
|
+
for (let i = from; i < Math.min(lines.length, from + GUARD_LOOKAHEAD); i++) {
|
|
224
|
+
const m = re.exec(lines[i]);
|
|
225
|
+
if (m)
|
|
226
|
+
return m[1] === '!' && bodyStops(lines, i) ? 'required' : 'probe';
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
/** Rule 6 — a file that WRITES the variable is not a consumer of the project's
|
|
231
|
+
* env contract for it. Covers the test save/restore idiom (`prev =
|
|
232
|
+
* process.env.X` in `beforeEach`, `process.env.X = prev` in `afterEach`), which
|
|
233
|
+
* reads the variable only to put it back. File-scoped extension of rule 3. */
|
|
234
|
+
function locallySupplied(text, name) {
|
|
235
|
+
const n = name.replace(/[$]/g, '\\$');
|
|
236
|
+
return (new RegExp(`(?:process|Bun)\\.env\\s*\\.\\s*${n}\\s*(?:\\+|\\?\\?|\\|\\|)?=(?![=>])`).test(text)
|
|
237
|
+
|| new RegExp(`(?:process|Bun)\\.env\\s*\\[\\s*['"]${n}['"]\\s*\\]\\s*=(?![=>])`).test(text)
|
|
238
|
+
|| new RegExp(`delete\\s+(?:process|Bun)\\.env\\s*\\.\\s*${n}\\b`).test(text)
|
|
239
|
+
|| new RegExp(`\\bos\\.environ\\s*\\[\\s*['"]${n}['"]\\s*\\]\\s*=(?![=])`).test(text)
|
|
240
|
+
|| new RegExp(`\\bos\\.[Ss]etenv\\s*\\(\\s*['"]${n}['"]`).test(text));
|
|
241
|
+
}
|
|
242
|
+
/** Rule 5b — a file that PRESENCE-CHECKS the variable positively anywhere treats
|
|
243
|
+
* it as an override, so every read of it in that file is a probe. Without this,
|
|
244
|
+
* the CONSUMING read inside the guarded branch — `if (process.env.PI_BIN) return
|
|
245
|
+
* {command: process.env.PI_BIN}` (pi-invocation.ts), `process.env.GOFER_PYTHON ?
|
|
246
|
+
* [process.env.GOFER_PYTHON] : [...]` (gofer) — reads as required. */
|
|
247
|
+
function locallyProbed(text, name) {
|
|
248
|
+
const e = `(?:process|Bun)\\.env\\s*\\.\\s*${name}`;
|
|
249
|
+
return (new RegExp(`\\b(?:if|while)\\s*\\(\\s*${e}\\b`).test(text)
|
|
250
|
+
|| new RegExp(`${e}\\s*\\?(?![.?])`).test(text)
|
|
251
|
+
|| new RegExp(`\\bBoolean\\s*\\(\\s*${e}\\b`).test(text));
|
|
252
|
+
}
|
|
253
|
+
/** Comparison on either side — a probe of the environment, not a consumption. */
|
|
254
|
+
function isCompared(before, after) {
|
|
255
|
+
if (/^\s*(?:===|!==|==|!=)/.test(after))
|
|
256
|
+
return true;
|
|
257
|
+
if (/(?:===|!==|==|!=)\s*$/.test(before))
|
|
258
|
+
return true;
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
/** Rule 3: the read is a WRITE — `process.env.X = …` (but not `==`/`===`), or
|
|
262
|
+
* `delete process.env.X`. Both mean the file SUPPLIES the variable rather than
|
|
263
|
+
* consuming it; mx5's seven test files are the assignment case and gofer's
|
|
264
|
+
* `wdio.packaged.conf.ts` (`delete process.env.GOFER_GDFORMAT`) is the delete
|
|
265
|
+
* case — a scrub of a developer override, the opposite of a requirement. */
|
|
266
|
+
function isAssigned(before, after) {
|
|
267
|
+
if (/^\s*=(?![=>])/.test(after))
|
|
268
|
+
return true;
|
|
269
|
+
if (/\bdelete\s*$/.test(before))
|
|
270
|
+
return true;
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
/** Every literal env read in one file's text. */
|
|
274
|
+
export function scanSource(file, text) {
|
|
275
|
+
const lines = text.split(/\r?\n/);
|
|
276
|
+
const reads = [];
|
|
277
|
+
for (let i = 0; i < lines.length; i++) {
|
|
278
|
+
const line = lines[i];
|
|
279
|
+
if (isCommentLine(line))
|
|
280
|
+
continue;
|
|
281
|
+
for (const matcher of MATCHERS) {
|
|
282
|
+
matcher.re.lastIndex = 0;
|
|
283
|
+
let m;
|
|
284
|
+
while ((m = matcher.re.exec(line)) !== null) {
|
|
285
|
+
const name = m[1];
|
|
286
|
+
const before = line.slice(0, m.index);
|
|
287
|
+
const raw = line.slice(m.index + m[0].length);
|
|
288
|
+
let stepAside = matcher.classify?.(m, raw) ?? null;
|
|
289
|
+
// Past the call's own closing paren, so an operator that applies to
|
|
290
|
+
// the RESULT is seen (`os.Getenv("X") == ""`).
|
|
291
|
+
const after = matcher.call ? raw.replace(/^\s*\)/, '') : raw;
|
|
292
|
+
// …and past any WRAPPING call's brackets, so `Number(process.env.X)
|
|
293
|
+
// || 32` (gofer-rag, the largest FP class the STEP-0 hand-read
|
|
294
|
+
// found) reads as defaulted. Stepping aside when the `||` in fact
|
|
295
|
+
// defaults an enclosing expression is a false NEGATIVE, which this
|
|
296
|
+
// check spends freely; a false rank-0 gate failure it does not.
|
|
297
|
+
// …and past a trailing accessor chain, so `process.env.X?.trim() ||
|
|
298
|
+
// '/tmp/…'` (push.ts) is the defaulted read it plainly is.
|
|
299
|
+
const unwrapped = after
|
|
300
|
+
.replace(/^(?:\s*\??\.\s*[A-Za-z_$][\w$]*(?:\([^()]*\))?)*/, '')
|
|
301
|
+
.replace(/^[\s)\]]+/, '');
|
|
302
|
+
if (stepAside === null && isAmbient(name))
|
|
303
|
+
stepAside = 'ambient';
|
|
304
|
+
if (stepAside === null && isAssigned(before, after))
|
|
305
|
+
stepAside = 'assigned';
|
|
306
|
+
if (stepAside === null && isCompared(before, unwrapped))
|
|
307
|
+
stepAside = 'compared';
|
|
308
|
+
if (stepAside === null && hasDefault(before, unwrapped, lines[i + 1]))
|
|
309
|
+
stepAside = 'default';
|
|
310
|
+
if (stepAside === null && locallySupplied(text, name))
|
|
311
|
+
stepAside = 'assigned';
|
|
312
|
+
if (stepAside === null && (isProbeHead(before) || locallyProbed(text, name)))
|
|
313
|
+
stepAside = 'probe';
|
|
314
|
+
// `if (!process.env.X) …` — required only if the body stops.
|
|
315
|
+
if (stepAside === null
|
|
316
|
+
&& /\b(?:if|while)\s*\(\s*!\s*$/.test(before)
|
|
317
|
+
&& !bodyStops(lines, i))
|
|
318
|
+
stepAside = 'probe';
|
|
319
|
+
if (stepAside === null) {
|
|
320
|
+
const v = assignedTo(before);
|
|
321
|
+
if (v !== null && guardVerdict(lines, i + 1, v) === 'probe')
|
|
322
|
+
stepAside = 'probe';
|
|
323
|
+
}
|
|
324
|
+
reads.push({ name, file, line: i + 1, construct: matcher.construct, stepAside });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return reads;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Scan a tree: tracked templates × required reads in tracked source.
|
|
332
|
+
*
|
|
333
|
+
* Inert (empty templates, no findings) when the tree is not a git work tree or
|
|
334
|
+
* carries no tracked env template — the ENOENT=pass contract.
|
|
335
|
+
*/
|
|
336
|
+
export function scanEnvTemplateClosure(cwd) {
|
|
337
|
+
const tracked = trackedFiles(cwd);
|
|
338
|
+
if (tracked === null)
|
|
339
|
+
return inertClosure();
|
|
340
|
+
const templates = tracked.filter(p => ENV_TEMPLATE_NAMES.includes(path.posix.basename(p)));
|
|
341
|
+
if (templates.length === 0)
|
|
342
|
+
return inertClosure();
|
|
343
|
+
const declared = new Set();
|
|
344
|
+
for (const t of templates) {
|
|
345
|
+
try {
|
|
346
|
+
for (const n of parseTemplate(readFileSync(path.join(cwd, t), 'utf8')))
|
|
347
|
+
declared.add(n);
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
// a tracked-but-absent template (dirty worktree) declares nothing
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const reads = [];
|
|
354
|
+
for (const f of tracked) {
|
|
355
|
+
if (!SOURCE_EXT_RE.test(f) || GENERATED_RE.test(f))
|
|
356
|
+
continue;
|
|
357
|
+
let text;
|
|
358
|
+
try {
|
|
359
|
+
text = readFileSync(path.join(cwd, f), 'utf8');
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
// Cheap prefilter. Case-insensitive substring, NOT a word boundary:
|
|
365
|
+
// `os.Getenv(` / `os.LookupEnv(` carry no boundary before `env`, and a
|
|
366
|
+
// boundary-anchored filter silently drops every Go file (caught by the
|
|
367
|
+
// go fixture in STEP 0).
|
|
368
|
+
if (!/env/i.test(text))
|
|
369
|
+
continue;
|
|
370
|
+
reads.push(...scanSource(f, text));
|
|
371
|
+
}
|
|
372
|
+
const missing = reads.filter(r => r.stepAside === null && !declared.has(r.name));
|
|
373
|
+
return { templates, declared, reads, missing };
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The gate's view: one finding per VARIABLE (first read site, source order), not
|
|
377
|
+
* one per read — a var read in four files is one artifact defect, and the ranked
|
|
378
|
+
* failure list is read by a human and seeds the autofix prompt.
|
|
379
|
+
*/
|
|
380
|
+
export function findMissingEnvDeclarations(cwd) {
|
|
381
|
+
const c = scanEnvTemplateClosure(cwd);
|
|
382
|
+
const seen = new Set();
|
|
383
|
+
const missing = [];
|
|
384
|
+
for (const r of c.missing) {
|
|
385
|
+
if (seen.has(r.name))
|
|
386
|
+
continue;
|
|
387
|
+
seen.add(r.name);
|
|
388
|
+
missing.push(r);
|
|
389
|
+
}
|
|
390
|
+
return { templates: c.templates, missing };
|
|
391
|
+
}
|
|
392
|
+
/** One finding, as the final gate would phrase it. */
|
|
393
|
+
export function envGateFailureText(t, templates) {
|
|
394
|
+
return (`env closure: \`${t.name}\` is required by ${t.file}:${t.line} (${t.construct}) but no `
|
|
395
|
+
+ `tracked env template declares it (${templates.join(', ')}) — a fresh clone cannot `
|
|
396
|
+
+ 'supply it.');
|
|
397
|
+
}
|
package/dist/task/final-gate.js
CHANGED
|
@@ -58,6 +58,7 @@ import { collectProjectEnv, pinnedLocalPort, runDeepRenderCheck } from './deep-r
|
|
|
58
58
|
import { resolveRunner, runnerEnv, isCommandNotFound } from './runner-resolve.js';
|
|
59
59
|
import { taskThatIntroduced } from './task-provenance.js';
|
|
60
60
|
import { findDanglingArtifacts, danglingGateFailureText } from './artifact-closure.js';
|
|
61
|
+
import { findMissingEnvDeclarations, envGateFailureText } from './env-template-closure.js';
|
|
61
62
|
import { findMissingServeEntry, serveEntryGateFailureText } from './serve-entry.js';
|
|
62
63
|
import { makefileRecipe } from './command-shrink.js';
|
|
63
64
|
function packageScripts(cwd) {
|
|
@@ -1604,6 +1605,22 @@ export async function runFinalIntegrationGate(cwd, timeoutMs = 900_000, bootGrac
|
|
|
1604
1605
|
catch {
|
|
1605
1606
|
// best-effort scan — a scanner fault must never break the gate
|
|
1606
1607
|
}
|
|
1608
|
+
// Env-template closure (mx5 run 19, nexttask 10): a shipped source file
|
|
1609
|
+
// requires an env var the shipped template never mentions. `seed.ts` read
|
|
1610
|
+
// `process.env.ADMIN_PHONE`/`ADMIN_PASSWORD`, `.env.example` declared neither,
|
|
1611
|
+
// `bun run seed` exited 1, and the autofix "fixed" it by writing the GITIGNORED
|
|
1612
|
+
// `.env` — so the committed tree still cannot seed and nothing at run end said
|
|
1613
|
+
// why. Same shape and rank as the dangling-artifact scan one layer up: naming
|
|
1614
|
+
// the ARTIFACT that is wrong, statically, instead of only the command that
|
|
1615
|
+
// failed. Inert on any tree with no tracked template (ENOENT = pass).
|
|
1616
|
+
try {
|
|
1617
|
+
const env = findMissingEnvDeclarations(cwd);
|
|
1618
|
+
for (const m of env.missing)
|
|
1619
|
+
fail(envGateFailureText(m, env.templates), 0);
|
|
1620
|
+
}
|
|
1621
|
+
catch {
|
|
1622
|
+
// best-effort scan — a scanner fault must never break the gate
|
|
1623
|
+
}
|
|
1607
1624
|
if (failures.length > 0) {
|
|
1608
1625
|
// Stable sort: boot/render (rank 0) leads, everything else keeps execution
|
|
1609
1626
|
// order. One failure keeps the exact single-failure wording; several become
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mjasnikovs/pi-task",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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",
|