harness-everything 0.35.0 → 0.36.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,288 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Test-Source Portability Lint
6
+ *
7
+ * The Windows CI matrix has failed on fixture paths that macOS/Linux never
8
+ * reject: a filename containing ':' and '"' (mechanism-2ay, run 39043848220)
9
+ * and a Windows device-namespace path (mechanism-2au, run 38760213406).
10
+ * This lint moves that failure class from "after push" to this local gate.
11
+ *
12
+ * Scanned: every .js file under ci/ (fixture creators, check scripts, tests).
13
+ *
14
+ * Rules, all requiring a whitespace-free single-token literal so prose,
15
+ * regex sources and message templates stay out of the net:
16
+ * WIN_DEVICE_PATH the literal begins with the \\.\ or \\?\ device
17
+ * namespace prefix
18
+ * WIN_RESERVED_NAME the literal, or a path segment of it, is CON PRN
19
+ * AUX NUL COM1-9 LPT1-9, with or without an extension
20
+ * WIN_INVALID_CHAR a path-like literal contains one of : * ? " < > |
21
+ * (clock-like 12:34 fragments are not counted; only
22
+ * verified URL/Node-specifier schemes with a value
23
+ * after the colon and wildcard-led glob segments are
24
+ * exempt, so bad:name.md and bad*name.md are flagged)
25
+ *
26
+ * The exemptions are contextual, and ci/mechanism-37-portability-lint.test.js
27
+ * holds the positive/negative corpus that locks them down.
28
+ *
29
+ * Known limits, kept deliberately narrow over noisy coverage: it does not
30
+ * flag spaced fixture names, trailing dots/spaces, bare single tokens
31
+ * without a path separator or extension (except reserved names), or
32
+ * invalid characters inside prose-like strings. Those still rely on review
33
+ * and the Windows CI matrix. A finding here is a tripwire, not proof of a
34
+ * Windows failure.
35
+ *
36
+ * Remedy: use a portable fixture name, platform-guard the code path, or mark
37
+ * one deliberate case with `portability-lint: allow <reason>` on its line.
38
+ *
39
+ * Usage: node ci/portability-lint.js
40
+ */
41
+
42
+ const fs = require('fs');
43
+ const path = require('path');
44
+
45
+ const SELF = path.resolve(__filename);
46
+ const SCAN_ROOT = path.resolve(__dirname);
47
+ const SKIP_DIRS = new Set(['fixtures', 'node_modules', '__pycache__']);
48
+ const EXTENSIONS = new Set(['.js', '.cjs', '.mjs']);
49
+ const PRAGMA = /portability-lint:\s*allow/;
50
+ const RESERVED_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;
51
+ const INVALID_CHARS = /[:*?"<>|]/;
52
+ const WILDCARD_CHARS = /[*?]/g;
53
+ const CLOCK_FRAGMENT = /\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?/g;
54
+ const DEVICE_NAMESPACE = /^(\\\\|\\)[.?](\\|$)/;
55
+ // A scheme is only exempt with a real authority (`https://...`) or as a
56
+ // known Node/tooling/repo-data specifier (`node:assert/strict`,
57
+ // `pkg:npm/...`, `HEAD:refs/heads/main`, `legacy:GRAPH-1@docs/x.md`). A bare
58
+ // word before the colon (`bad:name.md`) is a filename, not a scheme.
59
+ const URL_WITH_AUTHORITY = /^[a-z][a-z0-9+.-]*:\/\//i;
60
+ const SPECIFIER_SCHEME = /^(node|pkg|https?|git\+https?|ssh|file|mailto|data|github|fallback-task|HEAD|legacy|human):/i;
61
+ const DRIVE_PREFIX = /^[A-Za-z]:/;
62
+ // Strings that open like regex sources rather than filenames: a char-class
63
+ // or inline-group opener after a leading slash, a leading inline group, or a
64
+ // leading backslash escape such as \s or \d.
65
+ const REGEX_SOURCE = /^(?:\(\?[:=!]|\/\[|\/\(\?|\\[sSdDwWbB])/;
66
+ // Markup tags, <placeholder> roots, desync tails, and `${...}` templates are
67
+ // data under test, never fixture names.
68
+ const DATAISH_PREFIX = /^[<>{}\[\]$]/;
69
+ // A wildcard-led segment (`*.md`, `**`) marks the token as a glob pattern.
70
+ const GLOB_SEGMENT = /^[*?]/;
71
+ const REGEX_PREFIX_CHARS = '([{,;:=!&|?+-*%<>~^';
72
+ const REGEX_KEYWORDS = /(?:^|[^\w$])(return|typeof|case|in|of|new|delete|void|instanceof)\s*$/;
73
+
74
+ function extractLiterals(source) {
75
+ const literals = [];
76
+ let line = 1;
77
+ let i = 0;
78
+ const n = source.length;
79
+ while (i < n) {
80
+ const ch = source[i];
81
+ if (ch === '\n') {
82
+ line += 1;
83
+ i += 1;
84
+ continue;
85
+ }
86
+ if (ch === '/' && source[i + 1] === '/') {
87
+ const next = source.indexOf('\n', i);
88
+ i = next === -1 ? n : next;
89
+ continue;
90
+ }
91
+ if (ch === '/' && source[i + 1] === '*') {
92
+ let j = i + 2;
93
+ while (j < n && !(source[j] === '*' && source[j + 1] === '/')) {
94
+ if (source[j] === '\n') line += 1;
95
+ j += 1;
96
+ }
97
+ i = j + 2;
98
+ continue;
99
+ }
100
+ if (ch === '/' && regexPosition(source, i)) {
101
+ // Skip regex literals so quotes and brackets inside them never desync
102
+ // the scanner into reading code as a string.
103
+ let j = i + 1;
104
+ let inClass = false;
105
+ while (j < n) {
106
+ const c = source[j];
107
+ if (c === '\n') break;
108
+ if (c === '\\') {
109
+ j += 2;
110
+ continue;
111
+ }
112
+ if (c === '[') inClass = true;
113
+ else if (c === ']') inClass = false;
114
+ else if (c === '/' && !inClass) {
115
+ j += 1;
116
+ break;
117
+ }
118
+ j += 1;
119
+ }
120
+ i = j;
121
+ continue;
122
+ }
123
+ if (ch === "'" || ch === '"' || ch === '`') {
124
+ const quote = ch;
125
+ const startLine = line;
126
+ let value = '';
127
+ let j = i + 1;
128
+ let closed = false;
129
+ while (j < n) {
130
+ const c = source[j];
131
+ if (c === '\n') break;
132
+ if (c === '\\') {
133
+ if (j + 1 >= n) break;
134
+ value += source[j + 1];
135
+ j += 2;
136
+ continue;
137
+ }
138
+ if (c === quote) {
139
+ closed = true;
140
+ break;
141
+ }
142
+ value += c;
143
+ j += 1;
144
+ }
145
+ if (closed) {
146
+ literals.push({ line: startLine, value });
147
+ i = j + 1;
148
+ continue;
149
+ }
150
+ // Unterminated on this line: treat the quote as plain text, rescan.
151
+ i += 1;
152
+ continue;
153
+ }
154
+ i += 1;
155
+ }
156
+ return literals;
157
+ }
158
+
159
+ function regexPosition(source, i) {
160
+ let k = i - 1;
161
+ while (k >= 0 && (source[k] === ' ' || source[k] === '\t')) k -= 1;
162
+ if (k < 0 || source[k] === '\n') return true;
163
+ if (REGEX_PREFIX_CHARS.includes(source[k])) return true;
164
+ return REGEX_KEYWORDS.test(source.slice(Math.max(0, k - 12), k + 1));
165
+ }
166
+
167
+ function looksPathLike(value) {
168
+ const s = value.trim();
169
+ if (!s || s.length > 300) return false;
170
+ if (/[\\/]/.test(s)) return true;
171
+ return /\.[A-Za-z0-9]{1,8}$/.test(s); // bare filename with extension
172
+ }
173
+
174
+ function pathSegments(value) {
175
+ const body = DRIVE_PREFIX.test(value) ? value.slice(2) : value;
176
+ return body.split(/[\\/]+/).filter(Boolean);
177
+ }
178
+
179
+ function analyzeLiteral(literal) {
180
+ const findings = [];
181
+ const value = literal.value.trim();
182
+ if (!value) return findings;
183
+ if (DEVICE_NAMESPACE.test(value)) {
184
+ findings.push({
185
+ rule: 'WIN_DEVICE_PATH',
186
+ detail: `literal begins with the ${value.slice(0, 3)} device-namespace prefix`,
187
+ });
188
+ return findings;
189
+ }
190
+ if (DATAISH_PREFIX.test(value)) return findings;
191
+ if (/\s/.test(value)) return findings; // prose, templates, patch bodies
192
+
193
+ const segments = pathSegments(value);
194
+ const glob = segments.some((segment) => GLOB_SEGMENT.test(segment));
195
+
196
+ if (RESERVED_NAME.test(value)) {
197
+ findings.push({
198
+ rule: 'WIN_RESERVED_NAME',
199
+ detail: `literal "${value}" is a reserved Windows name`,
200
+ });
201
+ } else if (looksPathLike(value)) {
202
+ for (const segment of segments) {
203
+ if (RESERVED_NAME.test(segment)) {
204
+ findings.push({
205
+ rule: 'WIN_RESERVED_NAME',
206
+ detail: `segment "${segment}" is a reserved Windows name`,
207
+ });
208
+ }
209
+ }
210
+ }
211
+
212
+ if (
213
+ looksPathLike(value) &&
214
+ !URL_WITH_AUTHORITY.test(value) &&
215
+ !SPECIFIER_SCHEME.test(value) &&
216
+ !REGEX_SOURCE.test(value)
217
+ ) {
218
+ // The drive-letter colon is portable; everything after it is not.
219
+ const withoutDrive = DRIVE_PREFIX.test(value) ? value.slice(2) : value;
220
+ const withoutClocks = withoutDrive.replace(CLOCK_FRAGMENT, '');
221
+ const scanned = glob ? withoutClocks.replace(WILDCARD_CHARS, '') : withoutClocks;
222
+ const invalid = scanned.match(INVALID_CHARS);
223
+ if (invalid) {
224
+ findings.push({
225
+ rule: 'WIN_INVALID_CHAR',
226
+ detail: `literal "${value}" contains '${invalid[0]}'`,
227
+ });
228
+ }
229
+ }
230
+ return findings;
231
+ }
232
+
233
+ function collectFiles(dir, out) {
234
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
235
+ if (entry.isDirectory()) {
236
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
237
+ collectFiles(path.join(dir, entry.name), out);
238
+ continue;
239
+ }
240
+ if (!entry.isFile()) continue;
241
+ if (!EXTENSIONS.has(path.extname(entry.name))) continue;
242
+ const full = path.join(dir, entry.name);
243
+ if (full === SELF) continue;
244
+ out.push(full);
245
+ }
246
+ return out;
247
+ }
248
+
249
+ function main() {
250
+ console.log('=== 🧭 Harness test-source portability lint ===');
251
+
252
+ const files = collectFiles(SCAN_ROOT, []);
253
+ let literalCount = 0;
254
+ let findings = 0;
255
+
256
+ for (const file of files) {
257
+ const text = fs.readFileSync(file, 'utf8');
258
+ const lines = text.split('\n');
259
+ for (const literal of extractLiterals(text)) {
260
+ literalCount += 1;
261
+ const allowed =
262
+ PRAGMA.test(lines[literal.line - 1] || '') ||
263
+ PRAGMA.test(lines[literal.line - 2] || '');
264
+ if (allowed) continue;
265
+ for (const finding of analyzeLiteral(literal)) {
266
+ findings += 1;
267
+ const shown = literal.value.length > 80 ? `${literal.value.slice(0, 77)}...` : literal.value;
268
+ const rel = path.relative(path.resolve(SCAN_ROOT, '..'), file);
269
+ console.error(`❌ ${rel}:${literal.line} [${finding.rule}] ${finding.detail}`);
270
+ console.error(` literal: ${shown}`);
271
+ console.error(' fix: portable fixture name, platform guard, or `portability-lint: allow <reason>`');
272
+ }
273
+ }
274
+ }
275
+
276
+ console.log(`Scanned ${files.length} file(s), ${literalCount} single-line literal(s).`);
277
+ if (findings > 0) {
278
+ console.error(`❌ portability lint found ${findings} finding(s) in the Windows-hostile class.`);
279
+ process.exit(1);
280
+ }
281
+ console.log('✅ no Windows-hostile paths in scanned test sources.');
282
+ }
283
+
284
+ if (require.main === module) {
285
+ main();
286
+ }
287
+
288
+ module.exports = { extractLiterals, analyzeLiteral, looksPathLike, pathSegments };
@@ -19,7 +19,7 @@ This page is generated from checked-in runtime and workflow sources. Do not edit
19
19
  | Workflow | Node.js versions | Explicit triggers | npm quality gates |
20
20
  | --- | --- | --- | --- |
21
21
  | `behavioral-evals.yml` | 24 | schedule, workflow_dispatch | n/a |
22
- | `ci.yml` | 22, 24 | workflow_call | `test`, `test:mutations`, `test:release:semantics`, `waza:check`, `test:consistency`, `test:references`, `test:release`, `test:collision`, `test:plugin:openai`, `test:plugin:submission`, `test:platform:compatibility` |
22
+ | `ci.yml` | 22, 24 | workflow_call | `test`, `test:mutations`, `test:release:semantics`, `waza:check`, `test:consistency`, `test:references`, `test:release`, `test:collision`, `test:plugin:openai`, `test:plugin:submission`, `test:platform:compatibility`, `test:portability` |
23
23
  | `release.yml` | 24 | workflow_dispatch | n/a |
24
24
 
25
25
  ## Drift prevention
@@ -52,11 +52,42 @@ and on failure calls `client.session.prompt()` to push a synthetic follow-up
52
52
  message into the session - forcing the agent to keep working instead of
53
53
  actually stopping.
54
54
 
55
+ The verification run has a budget, not a fixed 60s cap: a healthy self-regression
56
+ suite takes around two minutes, so the default is 10 minutes and
57
+ `HARNESS_VERIFY_TIMEOUT_MS` overrides it. Every run is classified as `pass`
58
+ (exit 0), `test_failure` (a real non-zero exit), `timeout` (the budget actually
59
+ expired: `ETIMEDOUT`, or a kill with no exit status once the budget elapsed) or
60
+ `execution_error` (a spawn-level failure such as `ENOBUFS`/`ENOENT`, or an
61
+ external signal such as `SIGTERM`/`SIGINT` before the budget - a signal alone
62
+ is not a budget expiry).
63
+ Only `test_failure` confirms a failing check and advances the Rule-of-3
64
+ counter; `timeout` and `execution_error` are reported as uncompleted
65
+ verifications and never impersonate repeated failures. A timeout is reported as
66
+ `timed out after <n>s (set HARNESS_VERIFY_TIMEOUT_MS to raise it); last
67
+ output: ...`, a spawn-level failure as `could not run to completion (execution
68
+ error: ENOBUFS); output: ...` (an external signal names the signal).
69
+
70
+ Known operational limits of the synchronous runner: `execSync` blocks the
71
+ plugin's event loop for the duration of each script (worst case one budget per
72
+ script), and captured output is buffered up to 16 MiB. A run whose output
73
+ exceeds that buffer is classified as an `execution_error`, never as a failed
74
+ check, so buffer pressure cannot flip the circuit breaker. Moving verification
75
+ to an async `spawn` with streaming, bounded diagnostics is tracked as a
76
+ follow-up in issue #338.
77
+
55
78
  ### 3. Rule-of-3 zoom-out boundary (`tool.execute.before` + the verification observer above)
56
79
 
57
80
  Enforces Rule of 3 on repeated verification failures (same failing command +
58
- truncated error, not arbitrary tool failures - `tool.execute.after` has no
59
- normalized success/failure field to key a signature on for tools in general):
81
+ normalized failure evidence: the first test-level failing marker - TAP
82
+ `not ok`, node:test/jest symbols, Jest test blocks, assertion lines - over the
83
+ full captured stdout **and** stderr, with a preceding suite header such as
84
+ Jest's `FAIL <file>` carried as context so different tests in one file stay
85
+ distinct; without a test-level marker the signature falls back to a digest of
86
+ that full evidence, never to a suite header alone; the 2 KiB bound applies
87
+ only to the user-visible diagnostic excerpt, never to signature analysis; not
88
+ arbitrary tool failures, since
89
+ `tool.execute.after` has no normalized success/failure field to key a
90
+ signature on for tools in general):
60
91
  - 3rd failure on the same signature forces a reflection message instead of a
61
92
  retry nudge
62
93
  - the follow-up asks for a tokenized `zoom-out-report.md` artifact with the
@@ -105,19 +105,91 @@ function getAvailableScripts() {
105
105
  }
106
106
  }
107
107
 
108
+ // Mirrors ../index.mjs's verification budget and outcome classification so
109
+ // the manual CLI cannot drift from the plugin. A 60s cap killed healthy
110
+ // ~2 minute self-regression runs. Only `test_failure` (a real non-zero exit)
111
+ // is a confirmed failing check; `timeout` and `execution_error` mean the run
112
+ // produced no verdict. A signal alone is not a budget timeout - only expiry
113
+ // (ETIMEDOUT, or a kill with no exit status once the budget elapsed) is.
114
+ const VERIFY_TIMEOUT_ENV = 'HARNESS_VERIFY_TIMEOUT_MS';
115
+ const DEFAULT_VERIFY_TIMEOUT_MS = 600000;
116
+ const VERIFY_MAX_BUFFER = 16 * 1024 * 1024;
117
+ const VERIFY_OUTPUT_LIMIT = 2000;
118
+
119
+ function verifyTimeoutMs() {
120
+ const raw = Number(process.env[VERIFY_TIMEOUT_ENV]);
121
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_VERIFY_TIMEOUT_MS;
122
+ }
123
+
124
+ // Full failure evidence for signature analysis (stdout and stderr both, in
125
+ // full) - kept identical to ../index.mjs's inlined copy.
126
+ function verificationEvidence(stdout, stderr) {
127
+ const out = String(stdout || '');
128
+ const err = String(stderr || '');
129
+ if (out && err) return `${out}\nstderr:\n${err}`;
130
+ return out || err;
131
+ }
132
+
133
+ // Bounded excerpt that keeps both ends (npm banner plus failure tail).
134
+ function boundedDiagnostic(text, limit) {
135
+ const value = String(text || '');
136
+ if (value.length <= limit) return value;
137
+ const half = Math.floor(limit / 2);
138
+ return `${value.slice(0, half)}\n... [${value.length - half * 2} chars omitted] ...\n${value.slice(-half)}`;
139
+ }
140
+
141
+ // User-visible diagnostic: both streams get their own budget; a single
142
+ // non-empty stream stays label-free.
143
+ function verificationDiagnostic(stdout, stderr) {
144
+ const out = String(stdout || '');
145
+ const err = String(stderr || '');
146
+ if (out && err) {
147
+ const share = Math.floor(VERIFY_OUTPUT_LIMIT / 2);
148
+ return `stdout:\n${boundedDiagnostic(out, share)}\nstderr:\n${boundedDiagnostic(err, share)}`;
149
+ }
150
+ return boundedDiagnostic(out || err, VERIFY_OUTPUT_LIMIT);
151
+ }
152
+
153
+ function classifyVerificationError(error, startedAt, timeout) {
154
+ const code = String((error && error.code) || '');
155
+ const signal = String((error && error.signal) || '');
156
+ const exitCode = error && Number.isInteger(error.status) ? error.status : null;
157
+ const budgetExpired =
158
+ code === 'ETIMEDOUT' || (exitCode === null && Date.now() - startedAt >= timeout);
159
+ if (budgetExpired) return { outcome: 'timeout', exitCode, signal, timedOut: true };
160
+ if (exitCode === null || ['ENOBUFS', 'ENOENT', 'EACCES', 'EAGAIN', 'ENOMEM'].includes(code)) {
161
+ return { outcome: 'execution_error', exitCode, signal, timedOut: false };
162
+ }
163
+ return { outcome: 'test_failure', exitCode, signal, timedOut: false };
164
+ }
165
+
108
166
  function runVerification(command) {
167
+ const timeout = verifyTimeoutMs();
168
+ const startedAt = Date.now();
109
169
  try {
110
- execSync(command, {
170
+ execSync(command, {
111
171
  stdio: 'pipe',
112
- timeout: 60000 // 60 second timeout
172
+ timeout,
173
+ maxBuffer: VERIFY_MAX_BUFFER
113
174
  });
114
- return { success: true, command };
175
+ return { success: true, command, outcome: 'pass', exitCode: 0, signal: null, timedOut: false };
115
176
  } catch (e) {
116
- return {
117
- success: false,
118
- command,
119
- error: e.message.slice(0, 200)
120
- };
177
+ const stdout = (e.stdout || '').toString();
178
+ const stderr = (e.stderr || '').toString();
179
+ const output = verificationEvidence(stdout, stderr) || String((e && e.message) || '');
180
+ const { outcome, exitCode, signal, timedOut } = classifyVerificationError(e, startedAt, timeout);
181
+ let error;
182
+ if (outcome === 'timeout') {
183
+ error = `timed out after ${Math.round(timeout / 1000)}s (set ${VERIFY_TIMEOUT_ENV} to raise it)`;
184
+ } else if (outcome === 'execution_error') {
185
+ const reason = String((e && e.code) || (signal ? `signal ${signal}` : 'no exit status'));
186
+ error = `could not run to completion (execution error: ${reason}); output: ${output.slice(0, 200).replace(/\s+/g, ' ').trim() || '(none)'}`;
187
+ } else {
188
+ error = verificationDiagnostic(stdout, stderr) || String((e && e.message) || '');
189
+ }
190
+ const result = { success: false, command, outcome, exitCode, signal, timedOut, error };
191
+ if (outcome === 'test_failure' && output) result.evidence = output;
192
+ return result;
121
193
  }
122
194
  }
123
195
 
@@ -164,11 +236,13 @@ function main() {
164
236
  }
165
237
  }
166
238
 
167
- // Update state
239
+ // Update state. The full `evidence` strings are analysis-only (they can
240
+ // approach VERIFY_MAX_BUFFER); persist just the bounded diagnostic so
241
+ // edit-state.json stays small.
168
242
  state.verificationPending = !allPassed;
169
243
  state.editsSinceVerification = allPassed ? 0 : state.editsSinceVerification;
170
244
  state.lastVerificationTime = Date.now();
171
- state.verificationResults = results;
245
+ state.verificationResults = results.map(({ evidence, ...rest }) => rest);
172
246
 
173
247
  saveState(state);
174
248
 
@@ -179,7 +253,9 @@ function main() {
179
253
  allPassed,
180
254
  results: results.map(r => ({
181
255
  command: r.command,
182
- success: r.success
256
+ success: r.success,
257
+ outcome: r.outcome,
258
+ timedOut: r.timedOut === true
183
259
  })),
184
260
  message: allPassed
185
261
  ? 'All available verification passed. You can now complete.'