@mnemonik/shared 7.152.1 → 7.154.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/vueSource.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { SFCDescriptor } from '@vue/compiler-sfc';
2
2
  import { MAX_AST_PARSE_BYTES } from './ast/astChunker.js';
3
+ import { MAX_SCANNED_FILE_BYTES } from './codeScanner.js';
3
4
 
4
5
  /** A fallback scrub may repair syntax; it must never become graph evidence. */
5
6
  export const VUE_FIDELITY_MARKER = '<!-- mnemonik:vue-source-fidelity-unavailable -->';
@@ -10,7 +11,18 @@ export type VueTemplateNode = TemplateRoot | TemplateRoot['children'][number];
10
11
  type ParsedVueSource = {
11
12
  descriptor: SFCDescriptor;
12
13
  nodeTypes: typeof import('@vue/compiler-core').NodeTypes;
14
+ /** Set on the sanitation path only; the template walk throws past it. */
15
+ templateNodeLimit?: number;
13
16
  };
17
+
18
+ /**
19
+ * Sanitation runs on the API server's event loop (scan push, file push, documents), and
20
+ * its edit loop costs the file's length per edit, so it keeps the 2 MiB and 200,000-node
21
+ * bounds until it moves off that loop. Connected-code reads run in the parse pool and
22
+ * take any file up to the product's 10 MB ceiling.
23
+ */
24
+ const SANITATION_MAX_BYTES = MAX_AST_PARSE_BYTES;
25
+ const SANITATION_TEMPLATE_NODE_LIMIT = 200_000;
14
26
  export async function parseVueSource(
15
27
  source: string,
16
28
  forSanitation = false
@@ -18,7 +30,11 @@ export async function parseVueSource(
18
30
  // Sanitation still inspects marked bytes; graph extraction must continue to reject them.
19
31
  if (!forSanitation && source.includes(VUE_FIDELITY_MARKER))
20
32
  return { reason: 'vue_source_fidelity_unavailable' };
21
- if (Buffer.byteLength(source, 'utf8') > MAX_AST_PARSE_BYTES) return { reason: 'file_too_large' };
33
+ if (
34
+ Buffer.byteLength(source, 'utf8') >
35
+ (forSanitation ? SANITATION_MAX_BYTES : MAX_SCANNED_FILE_BYTES)
36
+ )
37
+ return { reason: 'file_too_large' };
22
38
  let clearCache: (() => void) | undefined;
23
39
  try {
24
40
  // Most shared consumers never scan Vue; do not load the compiler on their startup path.
@@ -40,7 +56,9 @@ export async function parseVueSource(
40
56
  descriptor.customBlocks.length
41
57
  )
42
58
  return { reason: 'vue_preprocessor_or_custom_block_unsupported' };
43
- return { descriptor, nodeTypes };
59
+ return forSanitation
60
+ ? { descriptor, nodeTypes, templateNodeLimit: SANITATION_TEMPLATE_NODE_LIMIT }
61
+ : { descriptor, nodeTypes };
44
62
  } catch {
45
63
  return { reason: 'vue_parse_failed' };
46
64
  } finally {
@@ -58,11 +76,13 @@ export function walkVueTemplate(
58
76
  const pending: Array<{ node: VueTemplateNode; scoped: boolean }> = descriptor.template?.ast
59
77
  ? [{ node: descriptor.template.ast, scoped: false }]
60
78
  : [];
79
+ // Iterative and linear in the template's nodes; only sanitation is bounded by count.
80
+ const limit = parsed.templateNodeLimit ?? Infinity;
61
81
  let count = 0;
62
82
  while (pending.length) {
63
83
  const item = pending.pop();
64
84
  if (!item) break;
65
- if (++count > 200_000) throw new Error('vue_template_work_limit');
85
+ if (++count > limit) throw new Error('vue_template_work_limit');
66
86
  const { node } = item;
67
87
  const scoped =
68
88
  item.scoped ||
@@ -0,0 +1,121 @@
1
+ import assert from 'node:assert/strict';
2
+ import { readdirSync } from 'node:fs';
3
+ import { mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { after, test } from 'node:test';
7
+ import {
8
+ GATE_OUTBOX_CAP,
9
+ GATE_OUTBOX_MAX_AGE_MS,
10
+ GATE_OUTBOX_MAX_EVENTS,
11
+ GATE_OUTBOX_STRAY_MS,
12
+ queueGateEvent,
13
+ returnGateEvents,
14
+ takeGateEvents,
15
+ } from '../src/gateOutbox.js';
16
+
17
+ // Gate telemetry rides the session's next injections request instead of an
18
+ // awaited POST of its own (about 30% of a PreToolUse hook's process time on
19
+ // 26 October 2026). These pin the queue: claimed once, bounded, re-sendable,
20
+ // never throws.
21
+
22
+ const roots: string[] = [];
23
+ after(async () => {
24
+ await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
25
+ });
26
+ async function outbox(): Promise<string> {
27
+ const root = await mkdtemp(join(tmpdir(), 'mnemonik-gate-outbox-'));
28
+ roots.push(root);
29
+ return join(root, 'session', 'gate-outbox');
30
+ }
31
+ const event = (outcome: string, at = Date.now()) => ({
32
+ tool: 'Bash',
33
+ mode: 'advisory',
34
+ outcome,
35
+ at,
36
+ });
37
+
38
+ test('queued events are taken once, oldest first, each with its own id', async () => {
39
+ const folder = await outbox();
40
+ const now = Date.now();
41
+ assert.equal(queueGateEvent(folder, event('b', now)), true);
42
+ queueGateEvent(folder, event('a', now - 10));
43
+ const taken = takeGateEvents(folder);
44
+ assert.deepEqual(
45
+ taken.map((e) => e.outcome),
46
+ ['a', 'b']
47
+ );
48
+ assert.equal(new Set(taken.map((e) => e.id)).size, 2);
49
+ // A second request of the same session finds nothing to send twice.
50
+ assert.deepEqual(takeGateEvents(folder), []);
51
+ });
52
+
53
+ test('events a request could not deliver go back, with the same ids', async () => {
54
+ const folder = await outbox();
55
+ queueGateEvent(folder, event('x'));
56
+ const taken = takeGateEvents(folder);
57
+ returnGateEvents(folder, taken);
58
+ assert.deepEqual(
59
+ takeGateEvents(folder).map((e) => e.id),
60
+ taken.map((e) => e.id)
61
+ );
62
+ });
63
+
64
+ test('an empty or missing outbox yields nothing', async () => {
65
+ assert.deepEqual(takeGateEvents(await outbox()), []);
66
+ });
67
+
68
+ test('stale and malformed events are dropped; a request carries at most the cap', async () => {
69
+ const folder = await outbox();
70
+ const now = Date.now();
71
+ queueGateEvent(folder, event('stale', now - GATE_OUTBOX_MAX_AGE_MS - 1));
72
+ for (let i = 0; i < GATE_OUTBOX_MAX_EVENTS + 5; i += 1)
73
+ queueGateEvent(folder, event(`e${i}`, now));
74
+ await writeFile(join(folder, '999999999999999-torn.json'), '{"tool":"Bash"');
75
+ const first = takeGateEvents(folder, now);
76
+ assert.equal(first.length, GATE_OUTBOX_MAX_EVENTS);
77
+ assert.ok(first.every((e) => e.outcome !== 'stale'));
78
+ const rest = takeGateEvents(folder, now);
79
+ assert.equal(first.length + rest.length, GATE_OUTBOX_MAX_EVENTS + 5);
80
+ assert.ok(GATE_OUTBOX_MAX_AGE_MS >= 24 * 60 * 60 * 1000);
81
+ });
82
+
83
+ test('a queue that cannot be written reports false instead of throwing', async () => {
84
+ // A regular file where the session folder should be: no folder can be made.
85
+ const folder = await outbox();
86
+ queueGateEvent(folder, event('x'));
87
+ const file = join(folder, 'not-a-folder');
88
+ await writeFile(file, 'x');
89
+ assert.equal(queueGateEvent(join(file, 'gate-outbox'), event('y')), false);
90
+ });
91
+
92
+ test('the outbox holds at most the cap, dropping the oldest', async () => {
93
+ // A server that stays unreachable: every request returns its events.
94
+ const folder = await outbox();
95
+ const now = Date.now();
96
+ for (let i = 0; i < GATE_OUTBOX_CAP + 20; i += 1) queueGateEvent(folder, event(`e${i}`, now + i));
97
+ const queued = readdirSync(folder).filter((name) => name.endsWith('.json'));
98
+ assert.equal(queued.length, GATE_OUTBOX_CAP);
99
+ const taken = takeGateEvents(folder, now + GATE_OUTBOX_CAP + 20);
100
+ assert.equal(taken[0]?.outcome, 'e20');
101
+ returnGateEvents(folder, taken);
102
+ queueGateEvent(folder, event('newest', now + GATE_OUTBOX_CAP + 21));
103
+ assert.equal(
104
+ readdirSync(folder).filter((name) => name.endsWith('.json')).length,
105
+ GATE_OUTBOX_CAP
106
+ );
107
+ });
108
+
109
+ test('temporary and claimed files a killed hook left are swept after ten minutes', async () => {
110
+ const folder = await outbox();
111
+ queueGateEvent(folder, event('x'));
112
+ const strayTmp = join(folder, '.000000000000001-a.json.tmp');
113
+ const strayClaim = join(folder, '.000000000000002-b.json.123.claimed');
114
+ const freshTmp = join(folder, '.000000000000003-c.json.tmp');
115
+ for (const path of [strayTmp, strayClaim, freshTmp]) await writeFile(path, '{}');
116
+ const old = new Date(Date.now() - GATE_OUTBOX_STRAY_MS - 60_000);
117
+ await utimes(strayTmp, old, old);
118
+ await utimes(strayClaim, old, old);
119
+ assert.equal(takeGateEvents(folder).length, 1);
120
+ assert.deepEqual(readdirSync(folder).sort(), ['.000000000000003-c.json.tmp']);
121
+ });