@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,280 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Neural Chain — git co-edit ingest helper.
5
+ *
6
+ * Implements the `git_co_edit` edge source for `chain_edges`. Co-edit pairs
7
+ * are derived from `git log --pretty=format:%H|%cI --name-only -n N HEAD`
8
+ * bounded to the last DEFAULT_MAX_COMMITS commits, filtered to the last
9
+ * WINDOW_DAYS days, with `.aioson/` framework state excluded and mega-commits
10
+ * (> MAX_FILES_PER_COMMIT files) skipped to bound the N² pair explosion.
11
+ *
12
+ * Confidence per BR-NC-01: `min(1.0, count / CONFIDENCE_SATURATION)`.
13
+ * Hard cap per BR-NC-08: HARD_CAP_PER_NODE active edges per source_path; oldest
14
+ * by `last_seen_at` is archived (`end_at = now`) before inserting beyond cap.
15
+ *
16
+ * EC-NC-06 (no git history): `runGitIngest` returns `{skipped: true,
17
+ * reason: 'insufficient_history'}` when `git rev-list --count HEAD` < MIN.
18
+ *
19
+ * Idempotent: re-running with the same commit window produces the same active
20
+ * state (UPSERT on the partial UNIQUE index over active rows).
21
+ *
22
+ * Two directional rows are stored per co-edit pair (A→B and B→A) to keep
23
+ * `chain:audit WHERE source_path = X` queries direct without UNION ALL.
24
+ *
25
+ * Internal representation: `Map<source_path, Map<target_path, { count, lastSeen }>>`.
26
+ * Nested map avoids string-separator ambiguity (file paths may contain any
27
+ * character including spaces and NUL bytes).
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const { isUnsafePath } = require('./neural-chain-sanitize');
32
+ const path = require('node:path');
33
+ const { execSync } = require('node:child_process');
34
+
35
+ const DEFAULT_MAX_COMMITS = 1000;
36
+ const CONFIDENCE_SATURATION = 10;
37
+ const WINDOW_DAYS = 90;
38
+ const MAX_FILES_PER_COMMIT = 50;
39
+ const HARD_CAP_PER_NODE = 10000;
40
+ const MIN_COMMITS_FOR_INGEST = 50;
41
+ const GIT_LOG_MAX_BUFFER = 32 * 1024 * 1024;
42
+
43
+ function parseGitLog(rawLog) {
44
+ const commits = [];
45
+ if (!rawLog || typeof rawLog !== 'string') return commits;
46
+
47
+ const lines = rawLog.split(/\r?\n/);
48
+ let current = null;
49
+ const headerPattern = /^([0-9a-f]{7,40})\|(.+)$/;
50
+
51
+ for (const rawLine of lines) {
52
+ const line = rawLine.replace(/\s+$/, '');
53
+ if (!line) {
54
+ if (current) {
55
+ commits.push(current);
56
+ current = null;
57
+ }
58
+ continue;
59
+ }
60
+
61
+ const headerMatch = line.match(headerPattern);
62
+ if (headerMatch) {
63
+ if (current) commits.push(current);
64
+ current = {
65
+ commit_hash: headerMatch[1],
66
+ committer_date_iso: headerMatch[2],
67
+ files: []
68
+ };
69
+ } else if (current) {
70
+ current.files.push(line);
71
+ }
72
+ }
73
+ if (current) commits.push(current);
74
+ return commits;
75
+ }
76
+
77
+ function bumpPair(bySource, source, target, isoDate) {
78
+ let inner = bySource.get(source);
79
+ if (!inner) {
80
+ inner = new Map();
81
+ bySource.set(source, inner);
82
+ }
83
+ const existing = inner.get(target);
84
+ if (existing) {
85
+ existing.count += 1;
86
+ if (isoDate > existing.lastSeen) existing.lastSeen = isoDate;
87
+ } else {
88
+ inner.set(target, { count: 1, lastSeen: isoDate });
89
+ }
90
+ }
91
+
92
+ function computeCoEditPairs(commits, { now = new Date() } = {}) {
93
+ const cutoff = new Date(now.getTime() - WINDOW_DAYS * 24 * 60 * 60 * 1000);
94
+ const bySource = new Map();
95
+
96
+ for (const commit of commits) {
97
+ const commitDate = new Date(commit.committer_date_iso);
98
+ if (Number.isNaN(commitDate.getTime()) || commitDate < cutoff) continue;
99
+
100
+ // Exclude framework state churn — .aioson/* changes every agent session.
101
+ // SF-NC-01/02 Layer B — also reject unsafe paths (control chars, oversize,
102
+ // empty). POSIX allows LF in filenames; git records them as-is. Without
103
+ // this filter, a crafted commit history could feed the noise-file
104
+ // injection vector identified in @pentester SF-NC-01.
105
+ const files = commit.files.filter(
106
+ (f) =>
107
+ f &&
108
+ !f.startsWith('.aioson/') &&
109
+ !f.startsWith('.aioson\\') &&
110
+ !isUnsafePath(f)
111
+ );
112
+ if (files.length < 2 || files.length > MAX_FILES_PER_COMMIT) continue;
113
+
114
+ for (let i = 0; i < files.length; i++) {
115
+ for (let j = 0; j < files.length; j++) {
116
+ if (i === j) continue;
117
+ bumpPair(bySource, files[i], files[j], commit.committer_date_iso);
118
+ }
119
+ }
120
+ }
121
+
122
+ return bySource;
123
+ }
124
+
125
+ function ingestGitCoEditEdges({ db, pairs, now = new Date() }) {
126
+ if (!db || typeof db.prepare !== 'function') {
127
+ throw new Error('ingestGitCoEditEdges requires an open better-sqlite3 db handle');
128
+ }
129
+
130
+ const nowIso = now.toISOString();
131
+ const stats = { upserted: 0, archived: 0, capped_inserts: 0 };
132
+
133
+ const upsertStmt = db.prepare(`
134
+ INSERT INTO chain_edges (source_path, target_path, edge_type, confidence, start_at, last_seen_at, hit_count)
135
+ VALUES (?, ?, 'git_co_edit', ?, ?, ?, ?)
136
+ ON CONFLICT(source_path, target_path, edge_type) WHERE end_at IS NULL
137
+ DO UPDATE SET
138
+ confidence = excluded.confidence,
139
+ last_seen_at = excluded.last_seen_at,
140
+ hit_count = excluded.hit_count
141
+ `);
142
+
143
+ const countActiveStmt = db.prepare(`
144
+ SELECT count(*) AS c FROM chain_edges
145
+ WHERE source_path = ? AND end_at IS NULL
146
+ `);
147
+
148
+ const findExistingStmt = db.prepare(`
149
+ SELECT id FROM chain_edges
150
+ WHERE source_path = ? AND target_path = ? AND edge_type = 'git_co_edit' AND end_at IS NULL
151
+ `);
152
+
153
+ const findOldestStmt = db.prepare(`
154
+ SELECT id FROM chain_edges
155
+ WHERE source_path = ? AND end_at IS NULL
156
+ ORDER BY last_seen_at ASC LIMIT 1
157
+ `);
158
+
159
+ const archiveStmt = db.prepare(`UPDATE chain_edges SET end_at = ? WHERE id = ?`);
160
+
161
+ // Flatten nested Map<source, Map<target, {count, lastSeen}>> into items.
162
+ const items = [];
163
+ for (const [source, inner] of pairs.entries()) {
164
+ if (!inner || typeof inner.entries !== 'function') continue;
165
+ for (const [target, value] of inner.entries()) {
166
+ items.push({ source, target, count: value.count, lastSeen: value.lastSeen });
167
+ }
168
+ }
169
+
170
+ const tx = db.transaction((arr) => {
171
+ for (const item of arr) {
172
+ const confidence = Math.min(1.0, item.count / CONFIDENCE_SATURATION);
173
+
174
+ // BR-NC-08 hard cap — only enforce when inserting a NEW edge for this source.
175
+ // Existing-edge updates (re-ingest) don't grow the active set.
176
+ const existingRow = findExistingStmt.get(item.source, item.target);
177
+ if (!existingRow) {
178
+ const { c: activeCount } = countActiveStmt.get(item.source);
179
+ if (activeCount >= HARD_CAP_PER_NODE) {
180
+ const oldest = findOldestStmt.get(item.source);
181
+ if (oldest) {
182
+ archiveStmt.run(nowIso, oldest.id);
183
+ stats.archived += 1;
184
+ stats.capped_inserts += 1;
185
+ }
186
+ }
187
+ }
188
+
189
+ upsertStmt.run(item.source, item.target, confidence, nowIso, item.lastSeen, item.count);
190
+ stats.upserted += 1;
191
+ }
192
+ });
193
+
194
+ tx(items);
195
+ return stats;
196
+ }
197
+
198
+ function getCommitCount(projectDir) {
199
+ try {
200
+ const output = execSync('git rev-list --count HEAD', {
201
+ cwd: projectDir,
202
+ encoding: 'utf8',
203
+ stdio: ['ignore', 'pipe', 'pipe']
204
+ });
205
+ const n = parseInt(output.trim(), 10);
206
+ return Number.isFinite(n) ? n : 0;
207
+ } catch (_) {
208
+ return -1;
209
+ }
210
+ }
211
+
212
+ function fetchGitLog(projectDir, maxCommits) {
213
+ return execSync(
214
+ `git log --pretty=format:%H|%cI --name-only -n ${Number(maxCommits)} HEAD`,
215
+ {
216
+ cwd: projectDir,
217
+ encoding: 'utf8',
218
+ stdio: ['ignore', 'pipe', 'pipe'],
219
+ maxBuffer: GIT_LOG_MAX_BUFFER
220
+ }
221
+ );
222
+ }
223
+
224
+ function runGitIngest({
225
+ db,
226
+ projectDir,
227
+ maxCommits = DEFAULT_MAX_COMMITS,
228
+ now = new Date(),
229
+ fetchLog = fetchGitLog
230
+ } = {}) {
231
+ if (!projectDir) {
232
+ return { skipped: true, reason: 'missing_project_dir' };
233
+ }
234
+ if (!fs.existsSync(path.join(projectDir, '.git'))) {
235
+ return { skipped: true, reason: 'no_git_repo' };
236
+ }
237
+
238
+ const commitCount = getCommitCount(projectDir);
239
+ if (commitCount < 0) {
240
+ return { skipped: true, reason: 'git_unavailable' };
241
+ }
242
+ if (commitCount < MIN_COMMITS_FOR_INGEST) {
243
+ return { skipped: true, reason: 'insufficient_history', commit_count: commitCount };
244
+ }
245
+
246
+ let rawLog;
247
+ try {
248
+ rawLog = fetchLog(projectDir, maxCommits);
249
+ } catch (err) {
250
+ return { skipped: true, reason: 'git_log_failed', error: err && err.message ? err.message : String(err) };
251
+ }
252
+
253
+ const commits = parseGitLog(rawLog);
254
+ const pairs = computeCoEditPairs(commits, { now });
255
+ const stats = ingestGitCoEditEdges({ db, pairs, now });
256
+
257
+ let pairsCount = 0;
258
+ for (const inner of pairs.values()) pairsCount += inner.size;
259
+
260
+ return {
261
+ skipped: false,
262
+ commit_count: commitCount,
263
+ commits_parsed: commits.length,
264
+ pairs_computed: pairsCount,
265
+ ...stats
266
+ };
267
+ }
268
+
269
+ module.exports = {
270
+ parseGitLog,
271
+ computeCoEditPairs,
272
+ ingestGitCoEditEdges,
273
+ runGitIngest,
274
+ DEFAULT_MAX_COMMITS,
275
+ CONFIDENCE_SATURATION,
276
+ WINDOW_DAYS,
277
+ MAX_FILES_PER_COMMIT,
278
+ HARD_CAP_PER_NODE,
279
+ MIN_COMMITS_FOR_INGEST
280
+ };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Neural Chain feature — Phase 1 schema migration.
5
+ *
6
+ * Adds chain_edges table + 3 indexes to .aioson/runtime/aios.sqlite per
7
+ * requirements-neural-chain.md § New entities and fields.
8
+ *
9
+ * Idempotent (IF NOT EXISTS guards on every step). Safe to call on every
10
+ * openRuntimeDb invocation. The 4 statements are all O(1) probes when the
11
+ * schema already exists, so no PRAGMA user_version sentinel is used —
12
+ * coordination with the existing learning-loop migration's user_version=3
13
+ * would require a shared versioning table (deferred until multiple features
14
+ * need migrations).
15
+ *
16
+ * Schema invariants enforced here:
17
+ * - edge_type ∈ {'git_co_edit', 'agent_event'} (BR-NC-01 sources)
18
+ * - 0.0 ≤ confidence ≤ 1.0 (BR-NC-01 formula range)
19
+ * - hit_count > 0 (BR-NC-07 ingest semantics)
20
+ * - source_path, target_path, edge_type uniqueness (active rows only,
21
+ * allowing archive flow per BR-NC-08 hard cap enforcement)
22
+ *
23
+ * Invariants enforced by application code, not schema:
24
+ * - validity-window discipline (start_at always set; end_at NULL in M1)
25
+ * - hard cap 10k per source_path node (audit at ingest time)
26
+ * - confidence formula and combination (max not sum)
27
+ */
28
+
29
+ const STEPS = [
30
+ `CREATE TABLE IF NOT EXISTS chain_edges (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ source_path TEXT NOT NULL,
33
+ target_path TEXT NOT NULL,
34
+ edge_type TEXT NOT NULL CHECK (edge_type IN ('git_co_edit', 'agent_event')),
35
+ confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0),
36
+ start_at TEXT NOT NULL,
37
+ end_at TEXT,
38
+ hit_count INTEGER NOT NULL DEFAULT 1 CHECK (hit_count > 0),
39
+ last_seen_at TEXT NOT NULL,
40
+ metadata TEXT
41
+ )`,
42
+ `CREATE INDEX IF NOT EXISTS idx_chain_edges_source
43
+ ON chain_edges(source_path, end_at)`,
44
+ `CREATE INDEX IF NOT EXISTS idx_chain_edges_target
45
+ ON chain_edges(target_path, end_at)`,
46
+ `CREATE UNIQUE INDEX IF NOT EXISTS uniq_chain_active
47
+ ON chain_edges(source_path, target_path, edge_type)
48
+ WHERE end_at IS NULL`
49
+ ];
50
+
51
+ function runMigration(db) {
52
+ if (!db || typeof db.exec !== 'function') {
53
+ throw new Error('runMigration requires an open better-sqlite3 database handle');
54
+ }
55
+
56
+ for (const stmt of STEPS) {
57
+ db.exec(stmt);
58
+ }
59
+ }
60
+
61
+ module.exports = { runMigration };
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Neural Chain — noise file write / lazy lifecycle (BR-NC-06).
5
+ *
6
+ * Produces `.aioson/context/noises/{feature-slug}-{YYYYMMDD-HHMM}.md` with a
7
+ * YAML frontmatter + body of markdown checkboxes. One file per session in
8
+ * `guarded` autonomy mode (other modes deferred to Slice 6 threshold rules).
9
+ *
10
+ * Lifecycle:
11
+ * writeNoiseFile() — create file with `- [ ]` items.
12
+ * readNoiseFileAndRecompute() — re-parse, count `- [x]`, surface stats.
13
+ * maybeDeleteNoiseFile() — unlink when no pending items remain.
14
+ *
15
+ * No file watcher — recompute is lazy, triggered by callers (chain:audit,
16
+ * @neo activation, agent_done hook). Idempotent unlink covers EC-NC-10.
17
+ * Item granularity is file-level only (M1 BR-NC-09); `:symbol` deferred to V2.
18
+ */
19
+
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+ const { isUnsafePath } = require('./neural-chain-sanitize');
23
+
24
+ const NOISE_DIR_REL = path.join('.aioson', 'context', 'noises');
25
+
26
+ function formatTimestamp(date) {
27
+ const yyyy = date.getUTCFullYear();
28
+ const mm = String(date.getUTCMonth() + 1).padStart(2, '0');
29
+ const dd = String(date.getUTCDate()).padStart(2, '0');
30
+ const hh = String(date.getUTCHours()).padStart(2, '0');
31
+ const min = String(date.getUTCMinutes()).padStart(2, '0');
32
+ return `${yyyy}${mm}${dd}-${hh}${min}`;
33
+ }
34
+
35
+ function sanitizeSlug(slug) {
36
+ if (slug === null || slug === undefined) return 'unspecified';
37
+ const s = String(slug)
38
+ .trim()
39
+ .toLowerCase()
40
+ .replace(/[^a-z0-9-]+/g, '-')
41
+ .replace(/^-+|-+$/g, '');
42
+ return s || 'unspecified';
43
+ }
44
+
45
+ function buildNoiseFilePath({ targetDir, featureSlug, now }) {
46
+ if (!targetDir || typeof targetDir !== 'string') {
47
+ throw new Error('buildNoiseFilePath requires targetDir');
48
+ }
49
+ const slug = sanitizeSlug(featureSlug);
50
+ const ts = formatTimestamp(now instanceof Date ? now : new Date());
51
+ return path.join(targetDir, NOISE_DIR_REL, `${slug}-${ts}.md`);
52
+ }
53
+
54
+ function serializeItem(item) {
55
+ const conf =
56
+ typeof item.confidence === 'number' && Number.isFinite(item.confidence)
57
+ ? item.confidence.toFixed(2)
58
+ : String(item.confidence ?? '0.00');
59
+ const sourceTag = item.source_file ? ` (source: ${item.source_file})` : '';
60
+ // BR-NC-03: classifier (Slice 6) tags items with `[AUTO-FIXABLE]` or
61
+ // `[AUTO-FIXABLE-BEST-EFFORT]` so the next agent session can execute them
62
+ // mechanically per BR-NC-04 handoff TODO contract.
63
+ const markerTag = item.marker ? ` [${item.marker}]` : '';
64
+ return `- [ ]${markerTag} ${item.target_path} — ${item.edge_type} ${conf}${sourceTag}`;
65
+ }
66
+
67
+ function buildContent({ slug, editAtIso, autonomyMode, sourceFiles, items }) {
68
+ const fm = [
69
+ '---',
70
+ `slug: ${slug}`,
71
+ `edit_at: ${editAtIso}`,
72
+ `autonomy_mode: ${autonomyMode}`,
73
+ `source_files: ${JSON.stringify(sourceFiles)}`,
74
+ `total_items: ${items.length}`,
75
+ `resolved_items: 0`,
76
+ '---'
77
+ ].join('\n');
78
+ const heading = '\n\n# Neural Chain — Impact Audit\n';
79
+ const intro =
80
+ '\nThe edits in this session may have impact on the files listed below. Tick each item once verified or addressed; this file is deleted automatically once all items are resolved.\n';
81
+ const body =
82
+ items.length === 0
83
+ ? '\n*No impacts detected.*\n'
84
+ : '\n' + items.map(serializeItem).join('\n') + '\n';
85
+ return fm + heading + intro + body;
86
+ }
87
+
88
+ function flattenAudits(audits) {
89
+ const items = [];
90
+ const sourceFilesSet = new Set();
91
+ let rejectedCount = 0;
92
+ for (const audit of audits || []) {
93
+ if (!audit) continue;
94
+ // SF-NC-01 Layer A — skip the audit's source_file from the frontmatter
95
+ // source_files list when it's unsafe (control chars / too long / empty).
96
+ // Defense in depth: even if Layer B (ingest) is bypassed, the noise
97
+ // file body must never carry attacker-controlled newlines.
98
+ if (audit.source_file && !isUnsafePath(audit.source_file)) {
99
+ sourceFilesSet.add(audit.source_file);
100
+ }
101
+ if (!Array.isArray(audit.impacts)) continue;
102
+ for (const impact of audit.impacts) {
103
+ if (!impact || !impact.target_path) continue;
104
+ const targetPath = String(impact.target_path);
105
+ // SF-NC-01 Layer A — drop the entire item when target_path is unsafe.
106
+ // The whole row is poisoned; rendering even a sanitized stub leaves the
107
+ // injection vector partially open. Telemetry-counting the rejection
108
+ // lives at Layer B (ingest); here we silently filter to keep the
109
+ // noise file shape coherent.
110
+ if (isUnsafePath(targetPath)) {
111
+ rejectedCount += 1;
112
+ continue;
113
+ }
114
+ const sourceFile = audit.source_file && !isUnsafePath(audit.source_file)
115
+ ? audit.source_file
116
+ : null;
117
+ items.push({
118
+ target_path: targetPath,
119
+ source_file: sourceFile,
120
+ edge_type: impact.edge_type ? String(impact.edge_type) : 'unknown',
121
+ confidence:
122
+ typeof impact.confidence === 'number' && Number.isFinite(impact.confidence)
123
+ ? impact.confidence
124
+ : 0,
125
+ // Slice 6: classifier may attach `marker` to impacts before passing
126
+ // them in (BR-NC-02/03). Pass through verbatim — flatten does not
127
+ // re-classify.
128
+ marker: impact.marker || null
129
+ });
130
+ }
131
+ }
132
+ return { items, sourceFiles: Array.from(sourceFilesSet), rejected: rejectedCount };
133
+ }
134
+
135
+ function writeNoiseFile({
136
+ targetDir,
137
+ featureSlug,
138
+ audits,
139
+ autonomyMode = 'guarded',
140
+ now = new Date()
141
+ }) {
142
+ if (!targetDir || typeof targetDir !== 'string') {
143
+ throw new Error('writeNoiseFile requires targetDir');
144
+ }
145
+ const stamp = now instanceof Date ? now : new Date();
146
+ const { items, sourceFiles } = flattenAudits(audits);
147
+ const slug = sanitizeSlug(featureSlug);
148
+ const filePath = buildNoiseFilePath({ targetDir, featureSlug, now: stamp });
149
+
150
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
151
+
152
+ const content = buildContent({
153
+ slug,
154
+ editAtIso: stamp.toISOString(),
155
+ autonomyMode,
156
+ sourceFiles,
157
+ items
158
+ });
159
+
160
+ fs.writeFileSync(filePath, content, 'utf8');
161
+
162
+ return {
163
+ path: filePath,
164
+ slug,
165
+ items,
166
+ total_items: items.length,
167
+ source_files: sourceFiles
168
+ };
169
+ }
170
+
171
+ function parseFrontmatter(text) {
172
+ if (!text.startsWith('---\n') && !text.startsWith('---\r\n')) {
173
+ return { ok: false, reason: 'missing_frontmatter', data: null, bodyOffset: 0 };
174
+ }
175
+ const lines = text.split(/\r?\n/);
176
+ let closing = -1;
177
+ for (let i = 1; i < lines.length; i += 1) {
178
+ if (lines[i].trim() === '---') {
179
+ closing = i;
180
+ break;
181
+ }
182
+ }
183
+ if (closing === -1) {
184
+ return { ok: false, reason: 'unclosed_frontmatter', data: null, bodyOffset: 0 };
185
+ }
186
+
187
+ const data = {};
188
+ for (let i = 1; i < closing; i += 1) {
189
+ const line = lines[i];
190
+ if (!line.trim() || line.trim().startsWith('#')) continue;
191
+ const m = line.match(/^([a-zA-Z0-9_]+)\s*:\s*(.*)$/);
192
+ if (!m) {
193
+ return {
194
+ ok: false,
195
+ reason: 'invalid_line',
196
+ data: null,
197
+ bodyOffset: 0,
198
+ badLine: line
199
+ };
200
+ }
201
+ const key = m[1];
202
+ const raw = m[2].trim();
203
+ let val;
204
+ if (raw === '') {
205
+ val = '';
206
+ } else if (raw.startsWith('[') && raw.endsWith(']')) {
207
+ try {
208
+ val = JSON.parse(raw);
209
+ } catch {
210
+ val = [];
211
+ }
212
+ } else if (
213
+ (raw.startsWith('"') && raw.endsWith('"')) ||
214
+ (raw.startsWith("'") && raw.endsWith("'"))
215
+ ) {
216
+ val = raw.slice(1, -1);
217
+ } else if (raw === 'true') {
218
+ val = true;
219
+ } else if (raw === 'false') {
220
+ val = false;
221
+ } else if (/^-?\d+$/.test(raw)) {
222
+ val = Number(raw);
223
+ } else if (/^-?\d+\.\d+$/.test(raw)) {
224
+ val = Number(raw);
225
+ } else {
226
+ val = raw;
227
+ }
228
+ data[key] = val;
229
+ }
230
+
231
+ const headerText = lines.slice(0, closing + 1).join('\n') + '\n';
232
+ return { ok: true, data, bodyOffset: headerText.length };
233
+ }
234
+
235
+ function parseItems(body) {
236
+ const items = [];
237
+ const lines = body.split(/\r?\n/);
238
+ // Optional marker bracket between checkbox and target_path, e.g.:
239
+ // - [ ] [AUTO-FIXABLE] src/foo.js — git_co_edit 0.80
240
+ // - [x] src/bar.js — agent_event 0.50
241
+ const re = /^- \[([ xX])\](?: \[([A-Z][A-Z0-9_-]*)\])? (.+?)(?: — (.+))?$/;
242
+ for (const line of lines) {
243
+ const m = re.exec(line);
244
+ if (!m) continue;
245
+ items.push({
246
+ checked: m[1] === 'x' || m[1] === 'X',
247
+ marker: m[2] || null,
248
+ target_path: m[3].trim(),
249
+ motivo: m[4] ? m[4].trim() : ''
250
+ });
251
+ }
252
+ return items;
253
+ }
254
+
255
+ function readNoiseFileAndRecompute({ path: filePath }) {
256
+ if (!filePath || typeof filePath !== 'string') {
257
+ throw new Error('readNoiseFileAndRecompute requires path');
258
+ }
259
+ let text;
260
+ try {
261
+ text = fs.readFileSync(filePath, 'utf8');
262
+ } catch (err) {
263
+ if (err && err.code === 'ENOENT') {
264
+ return {
265
+ exists: false,
266
+ frontmatter: null,
267
+ frontmatterOk: false,
268
+ frontmatterReason: 'not_found',
269
+ items: [],
270
+ allResolved: false,
271
+ pendingCount: 0,
272
+ resolvedCount: 0
273
+ };
274
+ }
275
+ throw err;
276
+ }
277
+
278
+ const fm = parseFrontmatter(text);
279
+ // EC-NC-09: corrupted frontmatter still allows body parsing — readable items preserved.
280
+ const body = fm.ok ? text.slice(fm.bodyOffset) : text;
281
+ const items = parseItems(body);
282
+ const resolved = items.filter((i) => i.checked).length;
283
+ const pending = items.length - resolved;
284
+
285
+ const frontmatter = fm.ok ? { ...fm.data, resolved_items: resolved } : null;
286
+
287
+ return {
288
+ exists: true,
289
+ frontmatter,
290
+ frontmatterOk: fm.ok,
291
+ frontmatterReason: fm.ok ? null : fm.reason,
292
+ items,
293
+ allResolved: items.length > 0 && pending === 0,
294
+ pendingCount: pending,
295
+ resolvedCount: resolved
296
+ };
297
+ }
298
+
299
+ function maybeDeleteNoiseFile({ path: filePath }) {
300
+ const r = readNoiseFileAndRecompute({ path: filePath });
301
+ if (!r.exists) {
302
+ return { deleted: false, reason: 'not_found' };
303
+ }
304
+ if (r.pendingCount === 0) {
305
+ try {
306
+ fs.unlinkSync(filePath);
307
+ return {
308
+ deleted: true,
309
+ reason: r.items.length === 0 ? 'no_items' : 'all_resolved'
310
+ };
311
+ } catch (err) {
312
+ // EC-NC-10: race between recompute and unlink — idempotent return.
313
+ if (err && err.code === 'ENOENT') {
314
+ return { deleted: false, reason: 'already_deleted' };
315
+ }
316
+ throw err;
317
+ }
318
+ }
319
+ return { deleted: false, reason: 'pending_items', pendingCount: r.pendingCount };
320
+ }
321
+
322
+ module.exports = {
323
+ writeNoiseFile,
324
+ readNoiseFileAndRecompute,
325
+ maybeDeleteNoiseFile,
326
+ buildNoiseFilePath,
327
+ sanitizeSlug,
328
+ formatTimestamp,
329
+ parseFrontmatter,
330
+ parseItems,
331
+ NOISE_DIR_REL
332
+ };
Binary file