instar 1.3.625 → 1.3.626

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.625",
3
+ "version": "1.3.626",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -28,13 +28,15 @@
28
28
  "test:contract": "node scripts/run-contract-tests.js",
29
29
  "test:contract:raw": "vitest run --config vitest.contract.config.ts",
30
30
  "test:all": "vitest run && vitest run --config vitest.integration.config.ts && vitest run --config vitest.e2e.config.ts",
31
- "lint": "tsc --noEmit && node scripts/lint-no-direct-destructive.js && node scripts/lint-no-direct-llm-http.js && node scripts/lint-no-direct-url-log.js && node scripts/lint-no-unfunneled-topic-creation.js && node scripts/lint-no-unfunneled-headless-launch.js && node scripts/lint-no-unfunneled-credential-write.js && node scripts/lint-state-registry.js && node scripts/lint-cas-emit-placement.js && node scripts/lint-journal-actuation-ban.js && node scripts/lint-no-blocking-process-scans.js && node scripts/lint-dev-agent-dark-gate.js && node scripts/lint-guard-manifest.js && node scripts/lint-llm-attribution.js && node scripts/lint-no-mainthread-cartographer-walk.js && node scripts/check-codex-rule1-drift.js",
31
+ "lint": "tsc --noEmit && node scripts/lint-no-direct-destructive.js && node scripts/lint-no-direct-llm-http.js && node scripts/lint-no-direct-url-log.js && node scripts/lint-no-unfunneled-topic-creation.js && node scripts/lint-no-unfunneled-headless-launch.js && node scripts/lint-no-unfunneled-credential-write.js && node scripts/lint-state-registry.js && node scripts/lint-cas-emit-placement.js && node scripts/lint-journal-actuation-ban.js && node scripts/lint-no-blocking-process-scans.js && node scripts/lint-dev-agent-dark-gate.js && node scripts/lint-guard-manifest.js && node scripts/lint-llm-attribution.js && node scripts/lint-no-mainthread-cartographer-walk.js && node scripts/lint-scrape-fixture-realness.js && node scripts/check-codex-rule1-drift.js",
32
32
  "lint:destructive": "node scripts/lint-no-direct-destructive.js",
33
33
  "lint:dev-agent-dark-gate": "node scripts/lint-dev-agent-dark-gate.js",
34
34
  "lint:guard-manifest": "node scripts/lint-guard-manifest.js",
35
35
  "lint:dev-agent-dark-gate:staged": "node scripts/lint-dev-agent-dark-gate.js --staged",
36
36
  "lint:destructive:staged": "node scripts/lint-no-direct-destructive.js --staged",
37
37
  "lint:llm-http": "node scripts/lint-no-direct-llm-http.js",
38
+ "lint:scrape-realness": "node scripts/lint-scrape-fixture-realness.js",
39
+ "lint:scrape-realness:staged": "node scripts/lint-scrape-fixture-realness.js --staged",
38
40
  "lint:llm-attribution": "node scripts/lint-llm-attribution.js",
39
41
  "lint:llm-attribution:staged": "node scripts/lint-llm-attribution.js --staged",
40
42
  "lint:llm-http:staged": "node scripts/lint-no-direct-llm-http.js --staged",
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * lint-scrape-fixture-realness.js — enforces that every REGISTERED scrape/parser
4
+ * has a test that FEEDS it a structurally-real captured fixture and asserts on
5
+ * the result (not a hand-authored clean string).
6
+ *
7
+ * Implements piece 3 of docs/specs/scrape-fixture-realness.md (the "code=t"
8
+ * lesson, structurally enforced). A parser of untrusted real-world text is only
9
+ * as good as the realness of its test input. The check is precise by
10
+ * construction — a CURATED registry (SCRAPE_PARSERS), not a heuristic over all
11
+ * tests — so false-positives are near-zero (same posture as
12
+ * lint-no-direct-llm-http / lint-dev-agent-dark-gate). Adding/removing a registry
13
+ * entry requires a spec change.
14
+ *
15
+ * For each registered parser the lint verifies (spec §3):
16
+ * (a) tests/fixtures/captured/<slug>/ has >=1 `.txt`, each with a valid matching
17
+ * `.meta.json` (required fields present + capturedAt parses as ISO date).
18
+ * (b) testFile contains a test whose name === testName, and within that test's
19
+ * body ALL of: a loadCapturedFixture('<slug>', ...) call, the parserSymbol
20
+ * called with the loaded var as an argument (member-expression accepted),
21
+ * and an expect( call.
22
+ *
23
+ * It ALSO scans src/ for exported parse-prefixed / scrape-prefixed symbols not covered by the
24
+ * registry and prints a non-blocking WARNING ("register-or-justify") — a
25
+ * Close-the-Loop signal, never a block (Signal vs. Authority).
26
+ *
27
+ * Exit codes:
28
+ * 0 — every registered entry conforms (warnings do NOT affect exit code).
29
+ * 1 — at least one registered entry fails a sub-check.
30
+ *
31
+ * Usage:
32
+ * node scripts/lint-scrape-fixture-realness.js # full check
33
+ * node scripts/lint-scrape-fixture-realness.js --staged # staged files (still runs full registry check)
34
+ * node scripts/lint-scrape-fixture-realness.js path1 path2 # explicit paths (still runs full registry check)
35
+ */
36
+
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
40
+
41
+ const __filename = fileURLToPath(import.meta.url);
42
+ const __dirname = path.dirname(__filename);
43
+ const ROOT = path.resolve(__dirname, '..');
44
+
45
+ /**
46
+ * The curated registry of parsers whose realness is enforced. Each entry:
47
+ * { parserSymbol, fixtureSlug, testFile, testName }
48
+ *
49
+ * Adding/removing an entry requires a spec change (FD1/FD3). The seed is the one
50
+ * parser we KNOW consumes untrusted terminal text and bit us (the code=t bug).
51
+ */
52
+ export const SCRAPE_PARSERS = [
53
+ {
54
+ parserSymbol: 'FrameworkLoginDriver.parseArtifact',
55
+ fixtureSlug: 'claude-url-code-paste',
56
+ testFile: 'tests/unit/framework-login-driver.test.ts',
57
+ testName: 'parses the REAL wrapped Mac Mini login pane',
58
+ },
59
+ ];
60
+
61
+ const REQUIRED_META_FIELDS = ['source', 'command', 'capturedAt', 'machine', 'redactions', 'note'];
62
+
63
+ /**
64
+ * (a) — verify the captured-fixture directory + sidecars for a slug.
65
+ * @returns {string[]} list of failure messages (empty = ok)
66
+ */
67
+ export function checkFixtures(root, fixtureSlug) {
68
+ const failures = [];
69
+ const dir = path.join(root, 'tests', 'fixtures', 'captured', fixtureSlug);
70
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
71
+ failures.push(`fixture dir missing: tests/fixtures/captured/${fixtureSlug}/`);
72
+ return failures;
73
+ }
74
+ const entries = fs.readdirSync(dir);
75
+ const txts = entries.filter((f) => f.endsWith('.txt'));
76
+ if (txts.length === 0) {
77
+ failures.push(`no .txt captures in tests/fixtures/captured/${fixtureSlug}/`);
78
+ return failures;
79
+ }
80
+ for (const txt of txts) {
81
+ const base = txt.slice(0, -'.txt'.length);
82
+ const sidecar = `${base}.meta.json`;
83
+ const sidecarPath = path.join(dir, sidecar);
84
+ if (!fs.existsSync(sidecarPath)) {
85
+ failures.push(`missing sidecar ${fixtureSlug}/${sidecar} for capture ${fixtureSlug}/${txt}`);
86
+ continue;
87
+ }
88
+ let meta;
89
+ try {
90
+ meta = JSON.parse(fs.readFileSync(sidecarPath, 'utf-8'));
91
+ } catch (err) {
92
+ failures.push(`sidecar ${fixtureSlug}/${sidecar} is not valid JSON: ${err.message}`);
93
+ continue;
94
+ }
95
+ for (const field of REQUIRED_META_FIELDS) {
96
+ if (!(field in meta)) {
97
+ failures.push(`sidecar ${fixtureSlug}/${sidecar} missing required field "${field}"`);
98
+ }
99
+ }
100
+ if (meta.capturedAt !== undefined) {
101
+ const t = Date.parse(meta.capturedAt);
102
+ if (Number.isNaN(t)) {
103
+ failures.push(`sidecar ${fixtureSlug}/${sidecar} capturedAt "${meta.capturedAt}" is not a parseable ISO-8601 date`);
104
+ }
105
+ }
106
+ }
107
+ return failures;
108
+ }
109
+
110
+ /**
111
+ * Extract the body text of an `it('<name>', ...)` / `test('<name>', ...)` block
112
+ * by matching the opening and walking braces to the matching close. Returns null
113
+ * if no test with that exact name is found.
114
+ */
115
+ export function extractTestBody(source, testName) {
116
+ // Find an it/test call whose first string-literal arg === testName.
117
+ // Match the literal (single, double, or backtick quotes) followed by a comma.
118
+ const escaped = testName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
119
+ const opener = new RegExp(`\\b(?:it|test)\\s*\\(\\s*(['"\`])${escaped}\\1\\s*,`, 'g');
120
+ const m = opener.exec(source);
121
+ if (!m) return null;
122
+ // Walk forward from the end of the matched opener to find the body's braces.
123
+ // The test body is the arrow/function passed as the 2nd arg; find its first
124
+ // `{` after the opener and brace-match to its close.
125
+ let i = opener.lastIndex;
126
+ // Skip ahead to the first `{` that starts the callback body.
127
+ const braceStart = source.indexOf('{', i);
128
+ if (braceStart < 0) return null;
129
+ let depth = 0;
130
+ for (let j = braceStart; j < source.length; j++) {
131
+ const ch = source[j];
132
+ if (ch === '{') depth++;
133
+ else if (ch === '}') {
134
+ depth--;
135
+ if (depth === 0) {
136
+ return source.slice(braceStart + 1, j);
137
+ }
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ /**
144
+ * (b) — verify the registered test exists and feeds-and-asserts.
145
+ * @returns {string[]} list of failure messages (empty = ok)
146
+ */
147
+ export function checkTest(root, entry) {
148
+ const failures = [];
149
+ const testPath = path.join(root, entry.testFile);
150
+ if (!fs.existsSync(testPath)) {
151
+ failures.push(`testFile missing: ${entry.testFile}`);
152
+ return failures;
153
+ }
154
+ const source = fs.readFileSync(testPath, 'utf-8');
155
+ const body = extractTestBody(source, entry.testName);
156
+ if (body === null) {
157
+ failures.push(`no test named "${entry.testName}" found in ${entry.testFile}`);
158
+ return failures;
159
+ }
160
+
161
+ // The loader call, capturing the variable it assigns to.
162
+ // const <v> = loadCapturedFixture('<slug>', ...)
163
+ const slugEsc = entry.fixtureSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
164
+ const loaderRe = new RegExp(
165
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*loadCapturedFixture\\s*\\(\\s*['"\`]${slugEsc}['"\`]`,
166
+ );
167
+ const loaderMatch = body.match(loaderRe);
168
+ if (!loaderMatch) {
169
+ failures.push(
170
+ `test "${entry.testName}" must call loadCapturedFixture('${entry.fixtureSlug}', …) and assign the result to a variable`,
171
+ );
172
+ }
173
+ const loadedVar = loaderMatch ? loaderMatch[1] : null;
174
+
175
+ // The parser call, fed the loaded variable as an argument.
176
+ // Accept member-expression (Cls.method) or bare/aliased call.
177
+ // parserSymbol may be "Cls.method" — escape dots literally.
178
+ const parserEsc = entry.parserSymbol.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
179
+ let parserFed = false;
180
+ if (loadedVar) {
181
+ const varEsc = loadedVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
182
+ // <parserSymbol>( ... <loadedVar> ... ) — loadedVar appears as an arg.
183
+ const parserRe = new RegExp(`${parserEsc}\\s*\\(\\s*${varEsc}\\b`);
184
+ parserFed = parserRe.test(body);
185
+ }
186
+ if (!parserFed) {
187
+ failures.push(
188
+ `test "${entry.testName}" must pass the loaded fixture variable as the first argument to ${entry.parserSymbol}(…)`,
189
+ );
190
+ }
191
+
192
+ // At least one expect( on the result.
193
+ if (!/\bexpect\s*\(/.test(body)) {
194
+ failures.push(`test "${entry.testName}" must assert on the parse result with expect(…)`);
195
+ }
196
+
197
+ return failures;
198
+ }
199
+
200
+ /** Recursively collect `.ts` files under a dir. */
201
+ function walkTs(dir, out, skip) {
202
+ let entries;
203
+ try {
204
+ entries = fs.readdirSync(dir, { withFileTypes: true });
205
+ } catch {
206
+ return;
207
+ }
208
+ for (const e of entries) {
209
+ if (skip.has(e.name)) continue;
210
+ const full = path.join(dir, e.name);
211
+ if (e.isDirectory()) walkTs(full, out, skip);
212
+ else if (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')) out.push(full);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Close-the-Loop signal: scan src/ for exported parse-prefixed / scrape-prefixed symbols not in
218
+ * the registry. Non-blocking — returns a warning list only.
219
+ * @returns {Array<{symbol:string,file:string}>}
220
+ */
221
+ export function findUnregisteredParsers(root) {
222
+ const skip = new Set(['node_modules', 'dist', 'build', '.instar', '.git', '.next', 'coverage']);
223
+ const files = [];
224
+ const srcDir = path.join(root, 'src');
225
+ if (fs.existsSync(srcDir)) walkTs(srcDir, files, skip);
226
+
227
+ // The bare method names already covered by the registry (e.g. "parseArtifact").
228
+ const registeredMethodNames = new Set(
229
+ SCRAPE_PARSERS.map((e) => {
230
+ const parts = e.parserSymbol.split('.');
231
+ return parts[parts.length - 1];
232
+ }),
233
+ );
234
+
235
+ const re = /export\s+(?:async\s+)?function\s+((?:parse|scrape)[A-Z]\w*)|export\s+const\s+((?:parse|scrape)[A-Z]\w*)/g;
236
+ const found = [];
237
+ const seen = new Set();
238
+ for (const file of files) {
239
+ let text;
240
+ try {
241
+ text = fs.readFileSync(file, 'utf-8');
242
+ } catch {
243
+ continue;
244
+ }
245
+ let m;
246
+ re.lastIndex = 0;
247
+ while ((m = re.exec(text)) !== null) {
248
+ const symbol = m[1] || m[2];
249
+ if (!symbol) continue;
250
+ if (registeredMethodNames.has(symbol)) continue;
251
+ const rel = path.relative(root, file).split(path.sep).join('/');
252
+ const key = `${rel}:${symbol}`;
253
+ if (seen.has(key)) continue;
254
+ seen.add(key);
255
+ found.push({ symbol, file: rel });
256
+ }
257
+ }
258
+ return found;
259
+ }
260
+
261
+ /**
262
+ * Run the full lint over the registry. Pure-ish (reads fs, returns a result).
263
+ * @returns {{ exitCode:number, errors:string[], warnings:string[], passed:Array }}
264
+ */
265
+ export function runLint(root = ROOT) {
266
+ const errors = [];
267
+ const passed = [];
268
+ for (const entry of SCRAPE_PARSERS) {
269
+ const failures = [...checkFixtures(root, entry.fixtureSlug), ...checkTest(root, entry)];
270
+ if (failures.length > 0) {
271
+ errors.push(
272
+ `Registered parser "${entry.parserSymbol}" (${entry.testFile}) failed realness check:\n` +
273
+ failures.map((f) => ` - ${f}`).join('\n'),
274
+ );
275
+ } else {
276
+ passed.push(entry);
277
+ }
278
+ }
279
+
280
+ const unregistered = findUnregisteredParsers(root);
281
+ const warnings = [];
282
+ if (unregistered.length > 0) {
283
+ warnings.push(
284
+ `register-or-justify: ${unregistered.length} exported parse*/scrape* symbol(s) in src/ are not in SCRAPE_PARSERS.\n` +
285
+ ` If they consume untrusted real-world text, register them (spec change) or justify out-of-scope in the PR:\n` +
286
+ unregistered.slice(0, 20).map((u) => ` • ${u.symbol} (${u.file})`).join('\n') +
287
+ (unregistered.length > 20 ? `\n • ...and ${unregistered.length - 20} more` : ''),
288
+ );
289
+ }
290
+
291
+ return { exitCode: errors.length > 0 ? 1 : 0, errors, warnings, passed };
292
+ }
293
+
294
+ function main() {
295
+ // --staged / explicit paths are accepted for parity with sibling lints, but
296
+ // the realness check is over the curated registry, so it always runs the full
297
+ // registry check regardless of which files changed.
298
+ const { exitCode, errors, warnings, passed } = runLint(ROOT);
299
+
300
+ if (errors.length > 0) {
301
+ console.error('lint-scrape-fixture-realness: registered parser(s) failed realness enforcement.\n');
302
+ console.error('A registered scrape/parser must have a test that FEEDS it a structurally-real captured');
303
+ console.error('fixture and asserts on the result. See docs/specs/scrape-fixture-realness.md.\n');
304
+ for (const e of errors) console.error(` ❌ ${e}`);
305
+ console.error('');
306
+ } else {
307
+ for (const e of passed) {
308
+ console.log(` ✅ ${e.parserSymbol} — realness fixture + feeds-and-asserts test OK (${e.testName})`);
309
+ }
310
+ }
311
+
312
+ if (warnings.length > 0) {
313
+ for (const w of warnings) console.warn(` ⚠️ ${w}`);
314
+ console.warn('');
315
+ }
316
+
317
+ process.exit(exitCode);
318
+ }
319
+
320
+ const isDirectRun = (() => {
321
+ try {
322
+ return process.argv[1] && path.resolve(process.argv[1]) === __filename;
323
+ } catch {
324
+ return false;
325
+ }
326
+ })();
327
+ if (isDirectRun) main();
@@ -428,6 +428,12 @@ try {
428
428
  warnings.push(`lint-no-direct-llm-http failed to run: ${err.message}`);
429
429
  }
430
430
 
431
+ // (The scrape-fixture-realness lint is enforced via the `npm run lint` chain in
432
+ // package.json, which CI runs — not duplicated here. A direct gate invocation
433
+ // would run against this gate's resolved ROOT, which is a scratch dir under the
434
+ // gate's own unit tests, where the registered fixtures don't exist; the chain in
435
+ // `npm run lint` runs in the real repo and is the authoritative enforcement.)
436
+
431
437
  // ── 6. URL.pathname filesystem guard ──────────────────────────────────
432
438
  // new URL(..., import.meta.url).pathname preserves %20-encoded spaces,
433
439
  // breaking filesystem operations. Use __dirname (via fileURLToPath) instead.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * redact-captured-fixture.mjs — same-SHAPE, grammar-valid secret redaction for
4
+ * captured parser fixtures.
5
+ *
6
+ * Captured fixtures (tests/fixtures/captured/<slug>/) preserve every STRUCTURAL
7
+ * byte that matters to parsing (wrapping/ANSI/spacing/line-breaks) — those are
8
+ * the realness and are NEVER tidied. But a real capture carries secrets (OAuth
9
+ * client_id/state, tokens, usernames, paths). Committing them raw is a leak, so
10
+ * each secret VALUE is replaced with a same-SHAPE placeholder:
11
+ *
12
+ * - SAME LENGTH (so line positions + wrapping stay byte-identical),
13
+ * - SAME CHARACTER CLASS (hex stays hex, base64url stays base64url, …),
14
+ * - delimiters preserved AT THEIR POSITIONS (a UUID keeps its `-` offsets),
15
+ * - GRAMMAR-VALID (a redacted URL still parses; encoding form intact).
16
+ *
17
+ * A redaction that would change length is REJECTED — a length change shifts the
18
+ * wrapping and silently produces a passing-but-fake fixture.
19
+ *
20
+ * See docs/specs/scrape-fixture-realness.md (FD2) and
21
+ * tests/fixtures/captured/README.md.
22
+ *
23
+ * Usage (CLI):
24
+ * node scripts/redact-captured-fixture.mjs <raw.txt> \
25
+ * --redact '<find>:<class>' [--redact '<find>:<class>' ...] [-o out.txt]
26
+ *
27
+ * <class> ∈ hex | uuid | base64url | alnum | token
28
+ *
29
+ * Programmatic:
30
+ * import { redactCapture } from './redact-captured-fixture.mjs';
31
+ * const { redacted, redactions } = redactCapture(raw, [{ find, class }]);
32
+ */
33
+
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ // The supported redaction classes. Adding a class requires a spec change
39
+ // (it widens the same-shape contract).
40
+ export const REDACTION_CLASSES = new Set(['hex', 'uuid', 'base64url', 'alnum', 'token']);
41
+
42
+ // Per-class placeholder fill characters. The fill is a single canonical char of
43
+ // the class so the result is obviously inert (not a plausible real secret),
44
+ // while staying in the class so the parser's grammar is preserved.
45
+ const CLASS_FILL = {
46
+ hex: 'a', // [0-9a-f]
47
+ uuid: 'a', // hex within UUID segments; hyphens are preserved positionally
48
+ base64url: 'z', // [A-Za-z0-9_-]
49
+ alnum: 'x', // [A-Za-z0-9]
50
+ token: 'x', // generic opaque token — treated like alnum for the fill
51
+ };
52
+
53
+ // Characters that are DELIMITERS within a value of a given class — preserved at
54
+ // their exact positions so the value's internal structure (and thus length +
55
+ // grammar) is unchanged. Everything else is replaced with the class fill char.
56
+ const CLASS_DELIMITERS = {
57
+ hex: new Set([]),
58
+ uuid: new Set(['-']),
59
+ base64url: new Set(['-', '_']),
60
+ alnum: new Set([]),
61
+ token: new Set(['-', '_', '.']),
62
+ };
63
+
64
+ /**
65
+ * Build a same-length, same-class, grammar-valid placeholder for a secret value.
66
+ * Delimiter characters for the class are preserved at their positions.
67
+ */
68
+ function placeholderFor(value, cls) {
69
+ const fill = CLASS_FILL[cls];
70
+ const delims = CLASS_DELIMITERS[cls];
71
+ let out = '';
72
+ for (const ch of value) {
73
+ out += delims.has(ch) ? ch : fill;
74
+ }
75
+ return out;
76
+ }
77
+
78
+ /**
79
+ * Redact secrets from a raw capture, producing a same-shape result.
80
+ *
81
+ * @param {string} raw the raw captured text (structural bytes preserved)
82
+ * @param {Array<{find:string, class:string}>} specs secrets to redact
83
+ * @returns {{ redacted: string, redactions: Array<{what:string,strategy:string,length:number,class:string}> }}
84
+ * @throws if a class is unknown, a `find` is missing from `raw`, or a
85
+ * placeholder would change length (it never should — this is a guard).
86
+ */
87
+ export function redactCapture(raw, specs) {
88
+ if (typeof raw !== 'string') throw new Error('redactCapture: raw must be a string');
89
+ if (!Array.isArray(specs)) throw new Error('redactCapture: specs must be an array');
90
+
91
+ let redacted = raw;
92
+ const redactions = [];
93
+
94
+ for (const spec of specs) {
95
+ const { find } = spec;
96
+ const cls = spec.class;
97
+ if (typeof find !== 'string' || find.length === 0) {
98
+ throw new Error('redactCapture: each spec needs a non-empty `find` string');
99
+ }
100
+ if (!REDACTION_CLASSES.has(cls)) {
101
+ throw new Error(
102
+ `redactCapture: unknown class "${cls}" for find "${find}" — must be one of ${[...REDACTION_CLASSES].join(', ')}`,
103
+ );
104
+ }
105
+ if (!redacted.includes(find)) {
106
+ throw new Error(`redactCapture: secret to redact not found in capture: "${find}"`);
107
+ }
108
+
109
+ const placeholder = placeholderFor(find, cls);
110
+ // Length-preservation guard: the whole point is byte-identical wrapping.
111
+ if (placeholder.length !== find.length) {
112
+ throw new Error(
113
+ `redactCapture: placeholder length ${placeholder.length} != original length ${find.length} ` +
114
+ `for class "${cls}" — a length-changing redaction shifts wrapping and is rejected.`,
115
+ );
116
+ }
117
+
118
+ redacted = redacted.split(find).join(placeholder);
119
+ redactions.push({
120
+ what: find,
121
+ strategy: `same-length ${find.length} ${cls}-class placeholder, delimiters preserved at positions`,
122
+ length: find.length,
123
+ class: cls,
124
+ });
125
+ }
126
+
127
+ return { redacted, redactions };
128
+ }
129
+
130
+ // ── thin CLI wrapper ──────────────────────────────────────────────────
131
+ function parseArgs(argv) {
132
+ const specs = [];
133
+ let input = null;
134
+ let out = null;
135
+ for (let i = 0; i < argv.length; i++) {
136
+ const a = argv[i];
137
+ if (a === '--redact') {
138
+ const v = argv[++i];
139
+ const idx = v.lastIndexOf(':');
140
+ if (idx < 0) throw new Error(`--redact expects '<find>:<class>', got "${v}"`);
141
+ specs.push({ find: v.slice(0, idx), class: v.slice(idx + 1) });
142
+ } else if (a === '-o' || a === '--out') {
143
+ out = argv[++i];
144
+ } else if (!input) {
145
+ input = a;
146
+ }
147
+ }
148
+ return { input, out, specs };
149
+ }
150
+
151
+ function main() {
152
+ const argv = process.argv.slice(2);
153
+ if (argv.length === 0 || argv.includes('--help') || argv.includes('-h')) {
154
+ console.log(
155
+ 'Usage: node scripts/redact-captured-fixture.mjs <raw.txt> --redact \'<find>:<class>\' [...] [-o out.txt]\n' +
156
+ ` <class> ∈ ${[...REDACTION_CLASSES].join(' | ')}`,
157
+ );
158
+ process.exit(argv.length === 0 ? 1 : 0);
159
+ }
160
+ const { input, out, specs } = parseArgs(argv);
161
+ const raw = fs.readFileSync(input, 'utf-8');
162
+ const { redacted, redactions } = redactCapture(raw, specs);
163
+ if (out) {
164
+ fs.writeFileSync(out, redacted);
165
+ console.error(`Wrote redacted capture to ${out}`);
166
+ } else {
167
+ process.stdout.write(redacted);
168
+ }
169
+ console.error(`Redactions (${redactions.length}):`);
170
+ for (const r of redactions) {
171
+ console.error(` - ${r.class} (len ${r.length}): ${r.strategy}`);
172
+ }
173
+ }
174
+
175
+ const isDirectRun = (() => {
176
+ try {
177
+ return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
178
+ } catch {
179
+ return false;
180
+ }
181
+ })();
182
+ if (isDirectRun) main();
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-06-19T06:08:39.918Z",
5
- "instarVersion": "1.3.625",
4
+ "generatedAt": "2026-06-19T06:11:32.609Z",
5
+ "instarVersion": "1.3.626",
6
6
  "entryCount": 201,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -7,6 +7,8 @@
7
7
 
8
8
  Adds a machine × account matrix on the dashboard Subscriptions tab — the on-demand "put account X on machine Y" surface that was missing (the account-follow-me Approve cards are quota-driven and never offered accounts on demand, so adding accounts to another machine meant a clunky chat dance). Rows are accounts, columns are machines; each cell is a ✓ (active there), a "Set up" button (not yet), or an honest in-progress / needs-reauth / offline / held state. Tapping "Set up" runs the whole sign-in IN the dashboard: it PIN-checks (operator presence — the agent shares the Bearer token, so the dashboard PIN is what proves a human is acting), drives the existing PIN→mandate→enroll-start chain to start a fresh login on the target machine (that machine re-mints its own login, ToS-safe), shows the auth link + a code box, and the shipped code-paste-back relay finishes it — flipping the cell to ✓. The wrong-account safety (S7 email-gate) and cross-machine delivery are reused unchanged. Dev-gated (`multiMachine.accountFollowMe`), dark on the fleet.
9
9
 
10
+ Structurally enforces the `code=t` lesson (the broken account-follow-me sign-in link, 2026-06-18): a parser of untrusted real-world text must be tested against a byte-for-byte REAL captured fixture, not a hand-authored clean string. Adds a Testing-Integrity standard, a `tests/fixtures/captured/` convention (structural bytes sacrosanct; secrets swapped for same-shape, grammar-valid placeholders via a tested redaction helper; one provenance sidecar per capture), and a registry-driven lint `scripts/lint-scrape-fixture-realness.js`. Each registered parser (seeded with `FrameworkLoginDriver.parseArtifact`) must have a test that loads a captured fixture through the single `loadCapturedFixture` helper, feeds it to the parser, and asserts — and the suite runs that test, so the realness is executed, not grepped. A non-blocking `parse*`/`scrape*` register-or-justify warning re-surfaces new parsers. The original `code=t` capture is migrated to disk (secrets redacted, hard-wrap preserved). No runtime code changes.
11
+
10
12
  ## What to Tell Your User
11
13
 
12
14
  There's now a grid on the Subscriptions tab showing which of your accounts are signed in on which machines, at a glance. To add an account to another machine, tap the empty cell, enter your dashboard PIN, sign in on the page it opens, and paste the code right there — it sets up that machine and the cell turns into a check mark. No more routing sign-in codes through chat.
@@ -21,3 +23,8 @@ There's now a grid on the Subscriptions tab showing which of your accounts are s
21
23
  - `tests/unit/subscriptions-render.test.ts` — matrix renders ✓ for active, "Set up" for empty, a disabled offline column for a `pool.failed` machine (no fabricated ✓), in-progress for a pending login.
22
24
  - `tests/unit/follow-me-controller-wiring.test.ts` — a "Set up" tap with a PIN POSTs start-cell {accountId, machineId, pin}; no-PIN does not POST.
23
25
  - `npx tsc --noEmit` clean; `npm run lint` (full battery) clean; 47 tests green.
26
+
27
+ - `tests/unit/lint-scrape-fixture-realness.test.ts` (10) — both boundary sides: the shipped registry entry passes; tampered cases (missing test, removed loader, missing/invalid sidecar) fail.
28
+ - `tests/unit/redact-captured-fixture.test.ts` (8) — same-length, line-position/wrap preservation, redacted URL still parses, length-changing redaction rejected.
29
+ - `tests/unit/framework-login-driver.test.ts` (17) — the migrated realness test loads the disk fixture, asserts the full de-wrapped URL ≠ `code=t`.
30
+ - `node scripts/lint-scrape-fixture-realness.js` exits 0 (seed entry clean + register-or-justify warning); `npm run lint` (full battery) exits 0; `tsc --noEmit` clean.
@@ -0,0 +1,49 @@
1
+ # Side-effects review — Scrape/Parser fixture-realness enforcement
2
+
3
+ Spec: `docs/specs/scrape-fixture-realness.md` (converged @ iter 4, approved) · ELI16: `docs/specs/scrape-fixture-realness.eli16.md`
4
+ Parent principle: Testing Integrity
5
+
6
+ ## What the change is
7
+
8
+ Structural enforcement of the `code=t` lesson: a parser of untrusted real-world text must be tested against a byte-for-byte REAL captured fixture, not a hand-authored clean string. Adds (1) a registry standard, (2) a `tests/fixtures/captured/` convention with secret-redaction + provenance sidecars, (3) a registry-driven lint `scripts/lint-scrape-fixture-realness.js` that requires each registered parser's test to load a captured fixture via `loadCapturedFixture`, feed it to the parser, and assert — plus a non-blocking register-or-justify warning for un-registered `parse*`/`scrape*` exports. No runtime code changes.
9
+
10
+ Files: `scripts/lint-scrape-fixture-realness.js`, `scripts/redact-captured-fixture.mjs`, `tests/helpers/loadCapturedFixture.ts`, `tests/fixtures/captured/**`, `docs/STANDARDS-REGISTRY.md`, `package.json` (lint chain), `scripts/pre-push-gate.js` (direct invocation), `tests/unit/framework-login-driver.test.ts` (migrated), + tests for the lint and the redaction helper.
11
+
12
+ ## 1. Over-block — legitimate inputs rejected that shouldn't be
13
+ The lint only enforces realness for parsers EXPLICITLY in the `SCRAPE_PARSERS` registry (seeded with one). It never fires on un-registered code — so it cannot false-positive a normal test. The register-or-justify check is a non-blocking WARNING (exit code unaffected). Near-zero over-block by construction (FD1).
14
+
15
+ ## 2. Under-block — failure modes still missed
16
+ - The lint proves the registered test loads-feeds-asserts; it cannot prove the assertion is non-trivial (a deliberately weak `expect`) — owned as a review concern (FD6).
17
+ - It cannot prove un-redacted bytes were genuinely real (provenance is review-checkable, not machine-verifiable) — owned (FD2).
18
+ - Inline/method/private parsers aren't caught by the `parse*`/`scrape*` export scan — the PR register-or-justify trigger (review) is primary; the warning is a backstop (FD3). This is the conformance gate's noted Structure-vs-Willpower residual, accepted because full coverage needs the unbounded heuristic FD1 rejects.
19
+
20
+ ## 3. Level-of-abstraction fit
21
+ Correct layer: a build-time lint alongside the existing `lint-*` family, run via `npm run lint` (the real battery) + the pre-push gate. The redaction helper sits in `scripts/` with its peers. The fixture convention sits under `tests/fixtures/`. No runtime layer touched.
22
+
23
+ ## 4. Signal vs authority compliance
24
+ A hard-blocking lint over a CURATED registry — legitimate per Signal-vs-Authority because the false-positive surface is near-zero by construction (identical posture to `lint-no-direct-llm-http` / `lint-dev-agent-dark-gate`, both hard-block on curated lists). The register-or-justify check is signal-only (warning, never blocks). No brittle blocking authority over messages/sessions.
25
+
26
+ ## 5. Interactions
27
+ - Adds one entry to the `npm run lint` `&&`-chain (ordering-independent; each lint is self-contained) + a direct pre-push invocation (mirrors the two existing direct lints). No shadowing/double-fire.
28
+ - The migrated `framework-login-driver` realness test now loads from disk via `loadCapturedFixture` instead of an inline const; the de-wrap assertion (full URL ≠ `code=t`) is unchanged, so the regression guard and the realness convention reinforce each other.
29
+ - The redaction helper is a new dependency of the fixture-authoring flow; it carries its own unit tests so a redaction bug can't silently produce a passing-but-fake fixture.
30
+
31
+ ## 6. External surfaces
32
+ None at runtime. Build-time only: a new lint contributors run via `npm run lint`; a new standard in the registry; a new fixtures dir. Nothing visible to users, other agents, or other systems.
33
+
34
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
35
+ Machine-local by design (FD4): build-time lint over the committed source tree + committed fixtures. No runtime surface, no state, no cross-machine behavior. Every machine builds the identical repo and runs the identical lint over the identical fixtures.
36
+
37
+ ## 8. Rollback cost
38
+ Cheap. Revert the new files + the three small wiring edits (package.json, pre-push-gate.js, the migrated test). No data migration, no runtime/state. The migrated fixture can revert to an inline const if ever needed (the assertion is unchanged).
39
+
40
+ ## Second-pass (high-risk: "lint"/gate)
41
+ The lint's core (`checkTest`) was independently re-read against the spec: it requires, within the registry-named test, a `loadCapturedFixture('<slug>')` assigned to a var, that var passed as the first arg to the registered parser symbol (member-expression accepted), and an `expect(`. The sidecar check validates required fields + ISO timestamp. Matches the spec's canonical-shape matcher; the realness is executed (the suite runs the named test). Concur.
42
+
43
+ ## Follow-up fix (CI regressions)
44
+
45
+ Two regressions this PR introduced were caught by CI (not the 3 files I first ran) and fixed:
46
+ 1. **Dangling ref:** the standard cited the `SCRAPE_PARSERS` token; the standards-enforcement-auditor resolves backticked `FOO_BAR` tokens as `src/**` symbols, and this one lives in `scripts/`, so it never resolved → danglingCount 0→1. Rephrased the standard to "a curated parser registry" (no marker token). Re-verified danglingCount=0.
47
+ - Over-block/Under-block: none — the lint's behavior is unchanged; only the registry prose changed.
48
+ 2. **Pre-push-gate scratch test:** the direct lint invocation added to `scripts/pre-push-gate.js` ran against the gate's resolved ROOT, which is a scratch dir in the gate's own unit tests (where the registered fixtures don't exist) → status 1. Removed the redundant direct invocation; the lint is enforced via the `npm run lint` chain that CI runs (authoritative). No loss of enforcement.
49
+ - Rollback cost: trivial (re-add the block if a future need arises, guarded for scratch).