@jaimevalasek/aioson 1.16.0 → 1.17.3

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,400 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Neural Chain — agent_event ingest + per-session audit hook helper.
5
+ *
6
+ * Slice 3 closes the second edge type ('agent_event'). Co-edit pairs come
7
+ * from the `artifacts` list passed to `agent:done` (typically the files
8
+ * the agent created or modified during its session). Each session adds
9
+ * +1 to hit_count for every directional pair (A→B, B→A) in the artifacts.
10
+ *
11
+ * Confidence per BR-NC-01: `min(1.0, hit_count / CONFIDENCE_SATURATION=5)`.
12
+ * V1 simplification: running `hit_count` is treated as the agent_event
13
+ * frequency signal. BR-NC-01 specifies `count_last_30d`; M2 graph
14
+ * maintenance will introduce aging. Saturation at 5 hits bounds the
15
+ * approximation regardless.
16
+ *
17
+ * Hard cap per BR-NC-08: HARD_CAP_PER_NODE active edges per source_path,
18
+ * oldest archived by `last_seen_at` before exceeding the cap.
19
+ *
20
+ * EC-NC-05: empty / single-file artifact lists yield zero pairs →
21
+ * `runChainHookOnAgentDone` still emits exactly one `chain_audit` event
22
+ * with `impacts_found=0` so the guardrail metric series stays unbroken.
23
+ *
24
+ * Best-effort by design (BR-NC-11): every write is wrapped so a failure
25
+ * never blocks the caller in `runAgentDone`.
26
+ */
27
+
28
+ const path = require('node:path');
29
+ const { writeNoiseFile } = require('./neural-chain-noise-file');
30
+ const {
31
+ readChainConfig,
32
+ DEFAULT_AUTONOMY_MODE,
33
+ DEFAULT_CHAIN_AUTO_THRESHOLD
34
+ } = require('./neural-chain-config');
35
+ const { emitChainAuditEvent } = require('./neural-chain-telemetry');
36
+ const { isUnsafePath } = require('./neural-chain-sanitize');
37
+
38
+ const CONFIDENCE_SATURATION = 5;
39
+ const HARD_CAP_PER_NODE = 10000;
40
+
41
+ // BR-NC-02 rule (a) — common test naming patterns across languages.
42
+ // {stem} below = source module basename minus its extension.
43
+ // {stem}.test.{ext} • {stem}.spec.{ext}
44
+ // test_{stem}.{ext} • {stem}_test.{ext} • {stem}-test.{ext}
45
+ const SOURCE_EXT_RE = /\.[a-zA-Z0-9]+$/;
46
+
47
+ function escapeRegex(s) {
48
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
49
+ }
50
+
51
+ function isTestFileFor(targetPath, sourcePath) {
52
+ if (!targetPath || !sourcePath) return false;
53
+ const sourceBase = path.basename(String(sourcePath));
54
+ const targetBase = path.basename(String(targetPath));
55
+ const stem = sourceBase.replace(SOURCE_EXT_RE, '');
56
+ if (!stem || stem === sourceBase) return false; // source has no extension
57
+ const s = escapeRegex(stem);
58
+ const re = new RegExp(
59
+ `^(${s}\\.(test|spec)\\.[a-zA-Z0-9]+|test_${s}\\.[a-zA-Z0-9]+|${s}_test\\.[a-zA-Z0-9]+|${s}-test\\.[a-zA-Z0-9]+)$`,
60
+ 'i'
61
+ );
62
+ return re.test(targetBase);
63
+ }
64
+
65
+ /**
66
+ * BR-NC-02/03 classifier. Returns the marker to embed in the noise item:
67
+ * - 'AUTO-FIXABLE' — matches BR-NC-02 (a) or (c) in standard/autonomous
68
+ * - 'AUTO-FIXABLE-BEST-EFFORT' — non-match in autonomous mode
69
+ * - null — non-match in standard mode, or guarded mode
70
+ *
71
+ * BR-NC-02 (b) (literal identifier match via diff) is deferred — requires
72
+ * git diff parsing the prior session's edits, heavy for V1; planned as a
73
+ * follow-up. Documented in spec § "Decisões arquiteturais desta slice".
74
+ */
75
+ function classifyImpact({ impact, sourceFile, autonomyMode, threshold }) {
76
+ if (autonomyMode === 'guarded') {
77
+ return { marker: null, classification: 'noise' };
78
+ }
79
+
80
+ const isTestPair = isTestFileFor(impact && impact.target_path, sourceFile);
81
+ const isThresholdMatch =
82
+ impact &&
83
+ typeof impact.confidence === 'number' &&
84
+ impact.confidence > threshold &&
85
+ impact.edge_type === 'agent_event' &&
86
+ typeof impact.hit_count === 'number' &&
87
+ impact.hit_count > 5;
88
+
89
+ if (isTestPair || isThresholdMatch) {
90
+ return { marker: 'AUTO-FIXABLE', classification: 'auto_fixable' };
91
+ }
92
+
93
+ if (autonomyMode === 'autonomous') {
94
+ return { marker: 'AUTO-FIXABLE-BEST-EFFORT', classification: 'auto_fixable_best_effort' };
95
+ }
96
+
97
+ return { marker: null, classification: 'noise' };
98
+ }
99
+
100
+ function deriveSessionPairs(artifacts) {
101
+ if (!Array.isArray(artifacts)) return [];
102
+
103
+ // SF-NC-01/02 Layer B — reject unsafe paths at ingest boundary BEFORE
104
+ // they reach chain_edges. Centralizes the newline/control-char check that
105
+ // shuts the BR-NC-03 guarded-mode bypass. Schema-level CHECK constraint
106
+ // deferred to M2 (SQLite ALTER TABLE doesn't support adding CHECK).
107
+ const files = artifacts.filter(
108
+ (f) =>
109
+ f &&
110
+ typeof f === 'string' &&
111
+ !f.startsWith('.aioson/') &&
112
+ !f.startsWith('.aioson\\') &&
113
+ !f.startsWith('.git/') &&
114
+ !f.startsWith('.git\\') &&
115
+ !isUnsafePath(f)
116
+ );
117
+
118
+ if (files.length < 2) return [];
119
+
120
+ const pairs = [];
121
+ for (let i = 0; i < files.length; i++) {
122
+ for (let j = 0; j < files.length; j++) {
123
+ if (i === j) continue;
124
+ pairs.push({ source: files[i], target: files[j] });
125
+ }
126
+ }
127
+ return pairs;
128
+ }
129
+
130
+ function ingestAgentEventEdges({ db, artifacts, now = new Date() }) {
131
+ if (!db || typeof db.prepare !== 'function') {
132
+ throw new Error('ingestAgentEventEdges requires an open better-sqlite3 db handle');
133
+ }
134
+
135
+ const pairs = deriveSessionPairs(artifacts);
136
+ if (pairs.length === 0) {
137
+ return { skipped: true, reason: 'no_pairs', upserted: 0, archived: 0, capped_inserts: 0 };
138
+ }
139
+
140
+ const nowIso = now.toISOString();
141
+ const stats = { skipped: false, upserted: 0, archived: 0, capped_inserts: 0 };
142
+ const initialConfidence = Math.min(1.0, 1 / CONFIDENCE_SATURATION);
143
+
144
+ // UPSERT: on conflict, increment hit_count + recompute confidence.
145
+ // SQLite ON CONFLICT semantics — `hit_count` (no qualifier) = current row's value;
146
+ // `excluded.<col>` = the value being inserted.
147
+ const upsertStmt = db.prepare(`
148
+ INSERT INTO chain_edges (source_path, target_path, edge_type, confidence, start_at, last_seen_at, hit_count)
149
+ VALUES (?, ?, 'agent_event', ?, ?, ?, 1)
150
+ ON CONFLICT(source_path, target_path, edge_type) WHERE end_at IS NULL
151
+ DO UPDATE SET
152
+ hit_count = hit_count + 1,
153
+ confidence = MIN(1.0, (hit_count + 1.0) / ${CONFIDENCE_SATURATION}.0),
154
+ last_seen_at = excluded.last_seen_at
155
+ `);
156
+
157
+ const countActiveStmt = db.prepare(`
158
+ SELECT count(*) AS c FROM chain_edges
159
+ WHERE source_path = ? AND end_at IS NULL
160
+ `);
161
+
162
+ const findExistingStmt = db.prepare(`
163
+ SELECT id FROM chain_edges
164
+ WHERE source_path = ? AND target_path = ? AND edge_type = 'agent_event' AND end_at IS NULL
165
+ `);
166
+
167
+ const findOldestStmt = db.prepare(`
168
+ SELECT id FROM chain_edges
169
+ WHERE source_path = ? AND end_at IS NULL
170
+ ORDER BY last_seen_at ASC LIMIT 1
171
+ `);
172
+
173
+ const archiveStmt = db.prepare('UPDATE chain_edges SET end_at = ? WHERE id = ?');
174
+
175
+ const tx = db.transaction((items) => {
176
+ for (const item of items) {
177
+ // BR-NC-08 hard cap — enforce only on new edges; existing-edge updates
178
+ // don't grow the active set.
179
+ const existingRow = findExistingStmt.get(item.source, item.target);
180
+ if (!existingRow) {
181
+ const { c: activeCount } = countActiveStmt.get(item.source);
182
+ if (activeCount >= HARD_CAP_PER_NODE) {
183
+ const oldest = findOldestStmt.get(item.source);
184
+ if (oldest) {
185
+ archiveStmt.run(nowIso, oldest.id);
186
+ stats.archived += 1;
187
+ stats.capped_inserts += 1;
188
+ }
189
+ }
190
+ }
191
+
192
+ upsertStmt.run(item.source, item.target, initialConfidence, nowIso, nowIso);
193
+ stats.upserted += 1;
194
+ }
195
+ });
196
+
197
+ tx(pairs);
198
+ return stats;
199
+ }
200
+
201
+ function queryImpacts(db, sourcePath, limit = 20) {
202
+ try {
203
+ // BR-NC-01 — when both `git_co_edit` and `agent_event` edges exist for
204
+ // the same (source, target), report max(c_git, c_event) — NOT both rows
205
+ // (which would double-count and duplicate items in the noise file).
206
+ // Window function dedupes per target_path, keeping the row with the
207
+ // highest confidence (tiebreaker by hit_count then last_seen_at).
208
+ // Hotfix v1.17.1 — bug-found-002 from @qa Gate D residual M-02.
209
+ return db.prepare(`
210
+ SELECT target_path, edge_type, confidence, hit_count, last_seen_at
211
+ FROM (
212
+ SELECT target_path, edge_type, confidence, hit_count, last_seen_at,
213
+ ROW_NUMBER() OVER (
214
+ PARTITION BY target_path
215
+ ORDER BY confidence DESC, hit_count DESC, last_seen_at DESC
216
+ ) AS rn
217
+ FROM chain_edges
218
+ WHERE source_path = ? AND end_at IS NULL
219
+ )
220
+ WHERE rn = 1
221
+ ORDER BY confidence DESC, hit_count DESC, last_seen_at DESC
222
+ LIMIT ?
223
+ `).all(sourcePath, limit);
224
+ } catch (_) {
225
+ return null;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Per-session hook called from `runAgentDone` after standard telemetry is
231
+ * written. Performs ingest + per-file audit telemetry + EC-NC-05 no-op
232
+ * fallback. Never throws; the caller wraps the invocation in try/catch
233
+ * defensively, but this function additionally guards each SQL boundary
234
+ * via try/catch and emitChainAuditEvent so a partial failure never
235
+ * propagates.
236
+ */
237
+ function runChainHookOnAgentDone({
238
+ db,
239
+ artifacts,
240
+ agentName = null,
241
+ featureSlug = null,
242
+ targetDir = null,
243
+ autonomyMode = null,
244
+ chainAutoThreshold = null,
245
+ now = new Date()
246
+ } = {}) {
247
+ if (!db || typeof db.prepare !== 'function') {
248
+ return { ok: false, reason: 'missing_db' };
249
+ }
250
+
251
+ // Slice 6: resolve autonomy mode + threshold from `.aioson/config.md`
252
+ // frontmatter when not explicitly provided. EC-NC-07: missing file /
253
+ // missing keys / invalid values → runtime defaults (guarded, 0.8).
254
+ let resolvedMode = autonomyMode;
255
+ let resolvedThreshold = chainAutoThreshold;
256
+ if ((resolvedMode === null || resolvedThreshold === null) && targetDir) {
257
+ try {
258
+ const cfg = readChainConfig({ targetDir });
259
+ if (resolvedMode === null) resolvedMode = cfg.autonomyMode;
260
+ if (resolvedThreshold === null) resolvedThreshold = cfg.chainAutoThreshold;
261
+ } catch (_) {
262
+ // best-effort
263
+ }
264
+ }
265
+ if (resolvedMode === null) resolvedMode = DEFAULT_AUTONOMY_MODE;
266
+ if (resolvedThreshold === null) resolvedThreshold = DEFAULT_CHAIN_AUTO_THRESHOLD;
267
+
268
+ const safeArtifacts = Array.isArray(artifacts) ? artifacts.slice() : [];
269
+
270
+ let ingest;
271
+ try {
272
+ ingest = ingestAgentEventEdges({ db, artifacts: safeArtifacts, now });
273
+ } catch (err) {
274
+ ingest = { skipped: true, reason: 'ingest_failed', error: err && err.message ? err.message : String(err) };
275
+ }
276
+
277
+ const audits = [];
278
+
279
+ if (safeArtifacts.length === 0) {
280
+ // EC-NC-05: zero edits → emit one no-op audit event so the guardrail
281
+ // metric series stays continuous.
282
+ emitChainAuditEvent(db, {
283
+ agent: agentName,
284
+ message: 'chain:audit (no-op: no artifacts in session)',
285
+ feature_slug: featureSlug,
286
+ source_files: [],
287
+ impacts_found: 0,
288
+ auto_fixable_count: 0,
289
+ noise_file: null,
290
+ tokens_used: 0,
291
+ duration_ms: 0,
292
+ error: null,
293
+ // Extra context fields
294
+ skipped_reason: 'no_artifacts',
295
+ autonomy_mode: resolvedMode,
296
+ chain_auto_threshold: resolvedThreshold,
297
+ ingest_stats: ingest,
298
+ // Legacy singular alias preserved for backward-compat with any dashboard
299
+ // queries written against the v1.17.0 schema (will be removed v2).
300
+ source_file: null
301
+ });
302
+ return { ok: true, ingest, audits, ec_nc_05: true, noise_file: null, autonomy_mode: resolvedMode };
303
+ }
304
+
305
+ // Pass 1 — collect impacts per source file AND classify each via
306
+ // BR-NC-02/03. Marker is attached to the impact in place so writeNoiseFile
307
+ // can render the prefix verbatim.
308
+ let autoFixableCount = 0;
309
+ for (const file of safeArtifacts) {
310
+ const startedAt = Date.now();
311
+ const rawImpacts = queryImpacts(db, file);
312
+ const durationMs = Date.now() - startedAt;
313
+ const classified = Array.isArray(rawImpacts)
314
+ ? rawImpacts.map((impact) => {
315
+ const { marker, classification } = classifyImpact({
316
+ impact,
317
+ sourceFile: file,
318
+ autonomyMode: resolvedMode,
319
+ threshold: resolvedThreshold
320
+ });
321
+ if (classification === 'auto_fixable') autoFixableCount += 1;
322
+ return { ...impact, marker, classification };
323
+ })
324
+ : [];
325
+ audits.push({
326
+ source_file: file,
327
+ impacts: classified,
328
+ impacts_found: rawImpacts === null ? 0 : classified.length,
329
+ duration_ms: durationMs,
330
+ error: rawImpacts === null ? 'query_failed' : null
331
+ });
332
+ }
333
+
334
+ // BR-NC-06/03: noise file is written in `standard` and `autonomous` modes
335
+ // too (Slice 6) — items carry the `[AUTO-FIXABLE]` / `[AUTO-FIXABLE-BEST-EFFORT]`
336
+ // prefix when applicable. `guarded` mode still writes one noise file with
337
+ // unprefixed items (same as Slice 4). Skip the write only when there are
338
+ // zero impacts across all artifacts.
339
+ let noiseFile = null;
340
+ const hasAnyImpacts = audits.some((a) => a.impacts_found > 0);
341
+ if (targetDir && hasAnyImpacts) {
342
+ try {
343
+ const result = writeNoiseFile({
344
+ targetDir,
345
+ featureSlug,
346
+ audits,
347
+ autonomyMode: resolvedMode,
348
+ now
349
+ });
350
+ noiseFile = result.path;
351
+ } catch (_) {
352
+ // BR-NC-11 best-effort: noise write must not block agent_done.
353
+ }
354
+ }
355
+
356
+ // Pass 2 — per-artifact telemetry events. noise_file is the same on every
357
+ // event (session-scoped) so dashboards can attribute the file regardless of
358
+ // which event row is sampled.
359
+ for (const audit of audits) {
360
+ emitChainAuditEvent(db, {
361
+ agent: agentName,
362
+ message: `chain:audit ${audit.source_file} → ${audit.error ? 'error' : `${audit.impacts_found} impacts`} (session hook)`,
363
+ feature_slug: featureSlug,
364
+ source_files: safeArtifacts,
365
+ impacts_found: audit.error ? null : audit.impacts_found,
366
+ auto_fixable_count: audit.error ? 0 : audit.impacts.filter((i) => i.classification === 'auto_fixable').length,
367
+ noise_file: noiseFile,
368
+ tokens_used: 0,
369
+ duration_ms: audit.duration_ms,
370
+ error: audit.error,
371
+ // Extra context fields
372
+ autonomy_mode: resolvedMode,
373
+ chain_auto_threshold: resolvedThreshold,
374
+ ingest_stats: ingest,
375
+ // Legacy singular alias preserved for backward-compat (removed v2)
376
+ source_file: audit.source_file
377
+ });
378
+ }
379
+
380
+ return {
381
+ ok: true,
382
+ ingest,
383
+ audits,
384
+ noise_file: noiseFile,
385
+ autonomy_mode: resolvedMode,
386
+ chain_auto_threshold: resolvedThreshold,
387
+ auto_fixable_count: autoFixableCount
388
+ };
389
+ }
390
+
391
+ module.exports = {
392
+ deriveSessionPairs,
393
+ ingestAgentEventEdges,
394
+ runChainHookOnAgentDone,
395
+ queryImpacts,
396
+ classifyImpact,
397
+ isTestFileFor,
398
+ CONFIDENCE_SATURATION,
399
+ HARD_CAP_PER_NODE
400
+ };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Neural Chain — config reader for autonomy mode + auto-fix threshold.
5
+ *
6
+ * Reads `.aioson/config.md` YAML frontmatter (if present) for two keys:
7
+ * - `autonomy_mode` → 'guarded' | 'standard' | 'autonomous'
8
+ * - `chain_auto_threshold` → REAL in [0.0, 1.0]
9
+ *
10
+ * EC-NC-07: missing file / missing frontmatter / missing key / invalid value
11
+ * → runtime defaults (`guarded`, 0.8). Never force-edits the config file.
12
+ *
13
+ * The current canonical `.aioson/config.md` is a pure documentation Markdown
14
+ * file with no frontmatter; users opt in to Neural Chain knobs by adding a
15
+ * `---` frontmatter block at the top of that file. See spec § BR-NC-03 +
16
+ * `requirements-neural-chain.md` § chain_auto_threshold for the contract.
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const { parseYamlFrontmatter } = require('./context');
22
+
23
+ const VALID_AUTONOMY_MODES = Object.freeze(['guarded', 'standard', 'autonomous']);
24
+ const DEFAULT_AUTONOMY_MODE = 'guarded';
25
+ const DEFAULT_CHAIN_AUTO_THRESHOLD = 0.8;
26
+
27
+ function normalizeAutonomyMode(value) {
28
+ if (typeof value !== 'string') return null;
29
+ const v = value.trim().toLowerCase();
30
+ return VALID_AUTONOMY_MODES.includes(v) ? v : null;
31
+ }
32
+
33
+ function normalizeThreshold(value) {
34
+ let n;
35
+ if (typeof value === 'number') {
36
+ n = value;
37
+ } else if (typeof value === 'string') {
38
+ n = parseFloat(value.trim());
39
+ } else {
40
+ return null;
41
+ }
42
+ if (!Number.isFinite(n) || n < 0 || n > 1) return null;
43
+ // SF-NC-03 — reject negative zero. JS quirk: -0 < 0 is false, so the range
44
+ // check above passes, but using -0 as a threshold is operationally
45
+ // equivalent to setting threshold=0 (everything above 0 confidence auto-
46
+ // fixes in standard/autonomous mode) and is a smell that the source value
47
+ // was crafted to dodge validation. Normalize positive zero only.
48
+ if (Object.is(n, -0)) return null;
49
+ return n;
50
+ }
51
+
52
+ function readChainConfig({ targetDir } = {}) {
53
+ const defaults = {
54
+ autonomyMode: DEFAULT_AUTONOMY_MODE,
55
+ chainAutoThreshold: DEFAULT_CHAIN_AUTO_THRESHOLD,
56
+ source: 'defaults'
57
+ };
58
+
59
+ if (!targetDir || typeof targetDir !== 'string') return defaults;
60
+
61
+ const filePath = path.join(targetDir, '.aioson', 'config.md');
62
+ let text;
63
+ try {
64
+ text = fs.readFileSync(filePath, 'utf8');
65
+ } catch (err) {
66
+ if (err && err.code === 'ENOENT') return defaults;
67
+ // Any other I/O error → defaults (best-effort, never blocks the hook).
68
+ return { ...defaults, source: 'read_error', error: err.message };
69
+ }
70
+
71
+ const parsed = parseYamlFrontmatter(text);
72
+ if (!parsed.ok) {
73
+ // No frontmatter / malformed → defaults. EC-NC-07 forbids force-editing.
74
+ return { ...defaults, source: 'no_frontmatter', reason: parsed.reason };
75
+ }
76
+
77
+ const autonomyMode = normalizeAutonomyMode(parsed.data.autonomy_mode) || DEFAULT_AUTONOMY_MODE;
78
+ const threshold = normalizeThreshold(parsed.data.chain_auto_threshold);
79
+ const chainAutoThreshold = threshold === null ? DEFAULT_CHAIN_AUTO_THRESHOLD : threshold;
80
+
81
+ return {
82
+ autonomyMode,
83
+ chainAutoThreshold,
84
+ source: 'config_md'
85
+ };
86
+ }
87
+
88
+ module.exports = {
89
+ readChainConfig,
90
+ normalizeAutonomyMode,
91
+ normalizeThreshold,
92
+ VALID_AUTONOMY_MODES,
93
+ DEFAULT_AUTONOMY_MODE,
94
+ DEFAULT_CHAIN_AUTO_THRESHOLD
95
+ };