@dogfood-lab/findings 1.3.2 → 1.5.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/reader.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readdirSync, existsSync, statSync } from 'node:fs';
7
- import { resolve, join, basename, extname } from 'node:path';
7
+ import { resolve, join, basename, extname, relative } from 'node:path';
8
8
  import { parseFinding, validateFinding } from './validate.js';
9
9
 
10
10
  /**
@@ -25,7 +25,18 @@ export function discoverFindings(rootDir) {
25
25
  const orgDir = join(findingsDir, org);
26
26
  for (const repo of listDirs(orgDir)) {
27
27
  const repoDir = join(orgDir, repo);
28
- for (const file of readdirSync(repoDir)) {
28
+ // Leaf-IO guard (B002): the two OUTER levels go through `listDirs`, whose
29
+ // statSync is wrapped in try/catch so one unreadable dir is skipped. The
30
+ // innermost leaf read was bare — a single repo dir going unreadable
31
+ // (EACCES on a locked/permission-restricted dir, an ENOTDIR, a transient
32
+ // FS error) would throw an unstructured Node stack out of discoverFindings
33
+ // and hence out of every consumer (validate / list / derivePatterns /
34
+ // advise / review queue), so one bad directory sank discovery for EVERY
35
+ // other repo. Mirror the package standard (load-records.js
36
+ // walkRecordsWithSkips, lib/safe-yaml-load.js walkDir): skip the bad
37
+ // repoDir instead of throwing, and name it on stderr so the operator sees
38
+ // WHICH directory was unreadable in CI logs rather than a raw stack.
39
+ for (const file of listLeafFiles(repoDir)) {
29
40
  if (extname(file) === '.yaml') {
30
41
  paths.push(resolve(repoDir, file));
31
42
  }
@@ -36,6 +47,40 @@ export function discoverFindings(rootDir) {
36
47
  return paths.sort();
37
48
  }
38
49
 
50
+ /**
51
+ * List the immediate entries of a leaf repo directory, guarding the bare
52
+ * `readdirSync` against EACCES/ENOTDIR/transient FS errors.
53
+ *
54
+ * On error the directory is SKIPPED (returns `[]`) rather than throwing, and a
55
+ * structured line naming the offending path is written to stderr so the failure
56
+ * is operator-visible in CI logs. `discoverFindings` returns a `string[]` of
57
+ * paths (no structured-skip channel rides the return shape), so this mirrors
58
+ * the `findRecordFile` precedent (derive/load-records.js) of surfacing the skip
59
+ * via stderr rather than silently swallowing it — a bad directory must not sink
60
+ * discovery for every other repo, but it must also not vanish without a trace.
61
+ *
62
+ * Exported (package-internal) so the B002 leaf-IO guard can be exercised
63
+ * directly with a real, portable readdir error (readdir on a non-directory
64
+ * throws ENOTDIR on every platform), since the listDirs+leaf walk couples
65
+ * statSync and readdirSync such that the failure cannot be staged through the
66
+ * full walk cross-platform.
67
+ *
68
+ * @param {string} repoDir - Absolute path to a findings/<org>/<repo> directory.
69
+ * @returns {string[]} Entry names, or `[]` if the directory could not be read.
70
+ */
71
+ export function listLeafFiles(repoDir) {
72
+ try {
73
+ return readdirSync(repoDir);
74
+ } catch (err) {
75
+ // eslint-disable-next-line no-console
76
+ console.error(
77
+ `discoverFindings: skipping unreadable findings directory ${repoDir}: ${err.message}. ` +
78
+ `Fix the directory's permissions/state and re-run; other repos were still discovered.`
79
+ );
80
+ return [];
81
+ }
82
+ }
83
+
39
84
  /**
40
85
  * Discover finding files from fixtures directory.
41
86
  * @param {string} rootDir - The dogfood-labs repo root.
@@ -84,9 +129,21 @@ export function loadFindings(rootDir, opts = {}) {
84
129
  * @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
85
130
  */
86
131
  export function findById(rootDir, findingId) {
132
+ // B-002 — collect torn finding files encountered during the scan. The
133
+ // finding_id lives INSIDE the file, so a torn file cannot be matched to the
134
+ // requested id; if the id turns out absent from every clean file, any torn
135
+ // file may be the one hiding it. Surfacing them on stderr (below) mirrors the
136
+ // derive CLI's "N finding(s) skipped (torn/unreadable)" honesty so a torn
137
+ // YAML never silently masquerades as a not-found id.
138
+ const torn = [];
139
+
87
140
  // Search real findings
88
141
  for (const filePath of discoverFindings(rootDir)) {
89
- const { data } = parseFinding(filePath);
142
+ const { data, error } = parseFinding(filePath);
143
+ if (error) {
144
+ torn.push({ path: filePath, error });
145
+ continue;
146
+ }
90
147
  if (data && data.finding_id === findingId) {
91
148
  const result = validateFinding(data);
92
149
  return { path: filePath, data, ...result };
@@ -95,13 +152,26 @@ export function findById(rootDir, findingId) {
95
152
 
96
153
  // Search valid fixtures
97
154
  for (const filePath of discoverFixtures(rootDir, 'valid')) {
98
- const { data } = parseFinding(filePath);
155
+ const { data, error } = parseFinding(filePath);
156
+ if (error) {
157
+ torn.push({ path: filePath, error });
158
+ continue;
159
+ }
99
160
  if (data && data.finding_id === findingId) {
100
161
  const result = validateFinding(data);
101
162
  return { path: filePath, data, ...result };
102
163
  }
103
164
  }
104
165
 
166
+ if (torn.length > 0) {
167
+ // eslint-disable-next-line no-console
168
+ console.error(`${torn.length} finding(s) skipped (torn/unreadable):`);
169
+ for (const t of torn) {
170
+ // eslint-disable-next-line no-console
171
+ console.error(` ${relative(rootDir, t.path)} — ${t.error}`);
172
+ }
173
+ }
174
+
105
175
  return null;
106
176
  }
107
177
 
@@ -1,36 +1,61 @@
1
1
  /**
2
2
  * Append-only review event log.
3
3
  *
4
- * Events are stored as YAML arrays in reviews/<YYYY>/<date>-finding-review-log.yaml
4
+ * Events are stored ONE PER FILE under reviews/<YYYY>/<YYYY-MM-DD>/<event-id>.yaml
5
+ * (the same sharded-immutable-file pattern the records/ store uses). This
6
+ * replaced an earlier "daily YAML array, read-modify-rename under a file lock"
7
+ * design: a Phase-9 50-fork race detector proved that NO lock FILE is reliably
8
+ * exclusive on NTFS under heavy concurrent create churn — two appenders could
9
+ * both win the lock, both read N events, both rename, and one clobber the
10
+ * other's event (~1/3 of saturated runs, every child still exiting 0). A
11
+ * shared mutable array file cannot be made safe under concurrency on that fs.
12
+ * One immutable file per event removes the shared mutable state entirely: each
13
+ * append writes a uniquely-named file, so concurrent appends CANNOT collide or
14
+ * lose an event, and no lock is needed for correctness. `getAllEventsWithSkips`
15
+ * already globs reviews/ recursively and merges per-file events.
5
16
  */
6
17
 
7
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
8
- import { resolve, dirname } from 'node:path';
18
+ import { mkdirSync, existsSync, openSync, writeSync, closeSync } from 'node:fs';
19
+ import { resolve } from 'node:path';
9
20
  import { randomBytes } from 'node:crypto';
10
21
  import yaml from 'js-yaml';
11
22
 
12
- import { withFileLock } from '../lib/file-lock.js';
13
- import { renameWithRetry } from '../lib/rename-with-retry.js';
14
23
  import { loadYamlDir } from '../lib/safe-yaml-load.js';
15
24
 
16
25
  let _eventCounter = 0;
17
26
 
18
27
  /**
19
- * Generate a unique event ID.
28
+ * Generate a unique event ID. Includes a random suffix so the id is unique
29
+ * ACROSS processes (the `_eventCounter` is per-process, so two forks would
30
+ * otherwise mint the same `rev-<ts>-<seq>`); this id also names the per-event
31
+ * file, so cross-process uniqueness keeps two concurrent appends from picking
32
+ * the same filename.
20
33
  */
21
34
  export function generateEventId() {
22
35
  const ts = Date.now().toString(36);
23
36
  const seq = (++_eventCounter).toString(36).padStart(4, '0');
24
- return `rev-${ts}-${seq}`;
37
+ const rand = randomBytes(4).toString('hex');
38
+ return `rev-${ts}-${seq}-${rand}`;
25
39
  }
26
40
 
27
41
  /**
28
42
  * Create a review event object.
43
+ *
44
+ * F2-INTEL-001 — back-compat extension for synthesis-artifact review events.
45
+ * The original event shape is keyed by `finding_id`; the synthesis-artifact
46
+ * review engine (review/review-artifacts.js) operates on patterns /
47
+ * recommendations / doctrine, which have no `finding_id`. When `params.artifactId`
48
+ * is supplied the event carries `artifact_id` + `artifact_kind` INSTEAD of
49
+ * `finding_id`, so the same append-only log and `getAllEvents` reader serve both
50
+ * object families. Finding events are byte-for-byte unchanged — the artifact
51
+ * fields are additive and only appear when `artifactId` is set.
29
52
  */
30
53
  export function createEvent(params) {
31
54
  const event = {
32
55
  review_event_id: generateEventId(),
33
- finding_id: params.findingId,
56
+ ...(params.artifactId
57
+ ? { artifact_id: params.artifactId, artifact_kind: params.artifactKind }
58
+ : { finding_id: params.findingId }),
34
59
  timestamp: new Date().toISOString(),
35
60
  actor: params.actor,
36
61
  action: params.action,
@@ -60,82 +85,52 @@ export function getLogPath(rootDir, date = new Date()) {
60
85
  }
61
86
 
62
87
  /**
63
- * Append an event to the review log.
88
+ * Directory holding one date-sharded set of per-event files:
89
+ * reviews/<YYYY>/<YYYY-MM-DD>/. Each appended event is its own file inside.
90
+ */
91
+ export function getEventDir(rootDir, date = new Date()) {
92
+ const year = String(date.getFullYear());
93
+ const month = String(date.getMonth() + 1).padStart(2, '0');
94
+ const day = String(date.getDate()).padStart(2, '0');
95
+ return resolve(rootDir, 'reviews', year, `${year}-${month}-${day}`);
96
+ }
97
+
98
+ /**
99
+ * Append a review event by writing it to its OWN immutable file
100
+ * (reviews/<YYYY>/<YYYY-MM-DD>/<event-id>.yaml). See the module header for why
101
+ * this replaced the daily-array-under-a-lock design: no shared mutable file
102
+ * means concurrent appends physically cannot collide or lose an event, so no
103
+ * lock is needed for correctness — the Phase-9 50-fork race detector that lost
104
+ * an event ~1/3 of saturated runs is structurally impossible here.
64
105
  *
65
- * Atomicity: writes the new event list to a unique temp file then renames it
66
- * over the canonical log file. `rename` is atomic on POSIX and Windows, so a
67
- * concurrent reader sees either the old contents or the new contents — never
68
- * a half-written file. This also makes the operation crash-safe: a Ctrl+C
69
- * between read and write leaves the original log intact.
106
+ * Crash-safety: a single `openSync(path, 'wx')` + write of one event is atomic
107
+ * at the file granularity — a crash mid-write leaves a complete event file or
108
+ * none, never a torn shared array. The filename is the (cross-process-unique)
109
+ * event id, so two concurrent appends never target the same path.
70
110
  *
71
- * Concurrency: serialized at the choke point via `withFileLock` on the daily
72
- * log file (F-PIPELINE-011 / W3-PIPE-001 — Pattern #4 choke-point fix). The
73
- * read-then-write window is closed by holding a directory-mutex (`<logPath>.lock`)
74
- * across the read → push → rename sequence. Two concurrent `appendEvent` calls
75
- * to the SAME daily log serialize against each other; calls to DIFFERENT daily
76
- * logs (e.g. across a midnight boundary) do not contend. The lock is reclaimed
77
- * if the holder process dies — see `lib/file-lock.js` for the full design
78
- * rationale (why a lock dir, why not `O_APPEND`, stale recovery semantics,
79
- * single-machine scope).
111
+ * @param {string} rootDir
112
+ * @param {object} event - a `createEvent()` result.
113
+ * @returns {string} the path of the event file written.
80
114
  */
81
115
  export function appendEvent(rootDir, event) {
82
- const logPath = getLogPath(rootDir);
83
- const dir = dirname(logPath);
116
+ const dir = getEventDir(rootDir);
84
117
  mkdirSync(dir, { recursive: true });
85
118
 
86
- // FAILS-then-PASSES proof gate (W3-PIPE-001):
87
- // Set DISABLE_APPEND_LOCK=1 in the env to bypass the lock for the explicit
88
- // purpose of demonstrating the race-detection test fails without the fix.
89
- // Wave-30 receipt documents the proof: with the lock, the multi-process
90
- // test passes 50/50 forks across 3 iterations, 20 consecutive test runs.
91
- // With the lock disabled, the test reliably fails (rename collisions on
92
- // unprotected concurrent rebuilds, dropped events).
93
- if (process.env.DISABLE_APPEND_LOCK) {
94
- let events = [];
95
- if (existsSync(logPath)) {
96
- const raw = readFileSync(logPath, 'utf-8');
97
- events = yaml.load(raw) || [];
98
- if (!Array.isArray(events)) events = [events];
99
- }
100
- events.push(event);
101
- const tmpSuffix = randomBytes(4).toString('hex');
102
- const tmpPath = `${logPath}.${tmpSuffix}.tmp`;
103
- writeFileSync(tmpPath, yaml.dump(events, { lineWidth: 120, noRefs: true }), 'utf-8');
104
- // Windows EPERM/EBUSY on rename can fire transiently when AV or
105
- // Search Indexer holds a handle to the freshly written temp. Retry.
106
- renameWithRetry(tmpPath, logPath);
107
- return logPath;
119
+ const eventId = event.review_event_id || generateEventId();
120
+ const eventPath = resolve(dir, `${eventId}.yaml`);
121
+ const body = yaml.dump(event, { lineWidth: 120, noRefs: true });
122
+
123
+ // 'wx' = O_EXCL exclusive-create: asserts the unique id has not collided
124
+ // (it carries a random suffix, so it won't) rather than relying on it. No
125
+ // temp+rename is needed — there is no shared file to atomically replace; one
126
+ // event per file is already all-or-nothing.
127
+ const fd = openSync(eventPath, 'wx');
128
+ try {
129
+ writeSync(fd, body);
130
+ } finally {
131
+ closeSync(fd);
108
132
  }
109
-
110
- return withFileLock(logPath, () => {
111
- let events = [];
112
- // Read-or-empty without an `existsSync` precheck: the readFileSync call
113
- // either returns the bytes or throws ENOENT. Avoiding `existsSync` here
114
- // closes a Windows-specific TOCTOU window where the dirent cache could
115
- // report `existsSync(logPath) === false` immediately after a sibling
116
- // process renamed a fresh file into place — which would cause us to
117
- // start with `events = []` and silently OVERWRITE the sibling's events.
118
- // The lock alone wasn't enough; the existsSync gate was the bug.
119
- try {
120
- const raw = readFileSync(logPath, 'utf-8');
121
- const parsed = yaml.load(raw);
122
- if (parsed) events = Array.isArray(parsed) ? parsed : [parsed];
123
- } catch (err) {
124
- if (!err || err.code !== 'ENOENT') throw err;
125
- }
126
-
127
- events.push(event);
128
-
129
- // Atomic write: temp file → rename. Same pattern persist.js + rebuild-indexes.js use.
130
- const tmpSuffix = randomBytes(4).toString('hex');
131
- const tmpPath = `${logPath}.${tmpSuffix}.tmp`;
132
- writeFileSync(tmpPath, yaml.dump(events, { lineWidth: 120, noRefs: true }), 'utf-8');
133
- // renameWithRetry: tolerate the Windows EPERM/EBUSY transient handle race
134
- // even though we hold the per-file lock — antivirus/Search Indexer can
135
- // still grab a handle on the temp during the rename window.
136
- renameWithRetry(tmpPath, logPath);
137
- return logPath;
138
- });
133
+ return eventPath;
139
134
  }
140
135
 
141
136
  /**
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Review engine for SYNTHESIS ARTIFACTS (patterns / recommendations / doctrine).
3
+ *
4
+ * F2-INTEL-001 — closes the intelligence loop. `synthesis/` derives patterns,
5
+ * recommendations, and doctrine and writes them with `status: 'candidate'`, but
6
+ * `advise/query.js` (`queryPatterns` / `queryRecommendations` / `queryDoctrine`)
7
+ * surfaces ONLY `status === 'accepted'`. At HEAD the finding review engine
8
+ * (`review/review-engine.js`) could promote findings, but NOTHING could promote
9
+ * a synthesis artifact candidate → accepted, so nothing the intelligence layer
10
+ * derived ever reached the advise surface. This module is the missing verb.
11
+ *
12
+ * It mirrors `performAction` (review/review-engine.js) and REUSES the finding
13
+ * status law verbatim (`validateTransition`, `ACTION_TARGET_STATUS`,
14
+ * `REASON_REQUIRED`, `REQUIRES_ACCEPTED`, `REQUIRES_CLOSED` from
15
+ * review/transitions.js). Persistence goes through the synthesis writers
16
+ * (`writePattern` / `writeRecommendation` / `writeDoctrine`), which re-validate
17
+ * the promoted artifact against its JSON Schema before it touches disk.
18
+ *
19
+ * Contract reality (load-bearing): the artifact schemas constrain `status` to a
20
+ * NARROWER set than findings —
21
+ * pattern : candidate | accepted | rejected | invalidated
22
+ * recommendation : candidate | accepted | rejected
23
+ * doctrine : candidate | accepted | rejected
24
+ * None of them permit `reviewed`. The finding law's intermediate `reviewed`
25
+ * state is therefore not expressible for artifacts: actions whose
26
+ * `ACTION_TARGET_STATUS` is `reviewed` (`review`, `reopen`) cannot persist a
27
+ * schema-valid artifact, and so are refused HONESTLY with a structured error
28
+ * rather than writing an artifact the contract would reject. `invalidate` is the
29
+ * one special case the contract supports — but only for patterns, which carry a
30
+ * literal `invalidated` status. Recommendations and doctrine lack it, so
31
+ * `invalidate` on those is refused with a structured hint. This is honest
32
+ * partial coverage, not a silent no-op.
33
+ */
34
+
35
+ import { relative } from 'node:path';
36
+
37
+ import { validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED, REQUIRES_ACCEPTED, REQUIRES_CLOSED } from './transitions.js';
38
+ import { createEvent, appendEvent } from './event-log.js';
39
+ import {
40
+ resetSeenArtifactWrite,
41
+ writePattern,
42
+ writeRecommendation,
43
+ writeDoctrine,
44
+ loadPatternsWithSkips,
45
+ loadRecommendationsWithSkips,
46
+ loadDoctrinesWithSkips
47
+ } from '../synthesis/write-artifacts.js';
48
+
49
+ /**
50
+ * Surface torn/unreadable artifact YAML on stderr — the lookup-path analogue of
51
+ * the derive CLI's "N pattern(s) skipped (torn/unreadable)" reporting
52
+ * (cli.js) and `findRecordFile`'s stderr note (derive/load-records.js).
53
+ *
54
+ * B-001 — `findArtifactById` / `getArtifactReviewQueue` consumed only the
55
+ * loader's `entries`, discarding `skipped[]`. A torn artifact YAML for the very
56
+ * id under accept/show/queue therefore vanished behind a bare "<type> not
57
+ * found", giving the operator no signal that a file was unreadable. The id
58
+ * lives INSIDE the file, so a torn file can't be matched to a requested id;
59
+ * the honest signal is to name the torn files whenever any are present so the
60
+ * operator can distinguish "absent" from "present but unparseable". Returns the
61
+ * skipped list so callers can decide whether the not-found was hint-worthy.
62
+ *
63
+ * @param {string} rootDir
64
+ * @param {string} type - artifact kind, for the message prefix
65
+ * @param {Array<{ path: string, error: string }>} skipped
66
+ */
67
+ function reportArtifactSkips(rootDir, type, skipped) {
68
+ if (!skipped || skipped.length === 0) return;
69
+ // eslint-disable-next-line no-console
70
+ console.error(`${skipped.length} ${type}(s) skipped (torn/unreadable):`);
71
+ for (const s of skipped) {
72
+ // eslint-disable-next-line no-console
73
+ console.error(` ${relative(rootDir, s.path)} — ${s.error}`);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Per-artifact-type configuration: the on-disk directory, the id field name,
79
+ * the loader (with-skips) and the writer. Mirrors how the finding engine pairs
80
+ * `findById` + writer; here the type is a first-class parameter.
81
+ */
82
+ const ARTIFACT_TYPES = {
83
+ pattern: {
84
+ dir: 'patterns',
85
+ idKey: 'pattern_id',
86
+ loadWithSkips: loadPatternsWithSkips,
87
+ write: writePattern,
88
+ statuses: new Set(['candidate', 'accepted', 'rejected', 'invalidated']),
89
+ hasInvalidatedStatus: true
90
+ },
91
+ recommendation: {
92
+ dir: 'recommendations',
93
+ idKey: 'recommendation_id',
94
+ loadWithSkips: loadRecommendationsWithSkips,
95
+ write: writeRecommendation,
96
+ statuses: new Set(['candidate', 'accepted', 'rejected']),
97
+ hasInvalidatedStatus: false
98
+ },
99
+ doctrine: {
100
+ dir: 'doctrine',
101
+ idKey: 'doctrine_id',
102
+ loadWithSkips: loadDoctrinesWithSkips,
103
+ write: writeDoctrine,
104
+ statuses: new Set(['candidate', 'accepted', 'rejected']),
105
+ hasInvalidatedStatus: false
106
+ }
107
+ };
108
+
109
+ /**
110
+ * Find a single synthesis artifact by id.
111
+ *
112
+ * @param {string} rootDir
113
+ * @param {'pattern'|'recommendation'|'doctrine'} type
114
+ * @param {string} id
115
+ * @returns {{ data: object, path: string, type: string } | null}
116
+ */
117
+ export function findArtifactById(rootDir, type, id) {
118
+ const cfg = ARTIFACT_TYPES[type];
119
+ if (!cfg) return null;
120
+ const { entries, skipped } = cfg.loadWithSkips(rootDir);
121
+ for (const entry of entries) {
122
+ if (entry.data && entry.data[cfg.idKey] === id) {
123
+ return { data: entry.data, path: entry.path, type };
124
+ }
125
+ }
126
+ // B-001 — id absent from the clean entries. A torn file may be hiding it;
127
+ // name the torn files rather than letting a bare not-found mislead.
128
+ reportArtifactSkips(rootDir, type, skipped);
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Perform a review action on a synthesis artifact — the artifact analogue of
134
+ * `performAction`. Promotes a candidate to accepted (closing the loop),
135
+ * rejects, or invalidates per the reused finding status law.
136
+ *
137
+ * @param {string} rootDir
138
+ * @param {object} params
139
+ * @param {'pattern'|'recommendation'|'doctrine'} params.type
140
+ * @param {string} params.id
141
+ * @param {string} params.action - accept | reject | review | reopen | invalidate
142
+ * @param {string} params.actor
143
+ * @param {string} [params.reason]
144
+ * @param {string} [params.notes]
145
+ * @returns {{ success: boolean, error?: string, artifact?: object, event?: object }}
146
+ */
147
+ export function reviewArtifact(rootDir, params) {
148
+ const { type, id, action, actor } = params;
149
+
150
+ if (!type || !ARTIFACT_TYPES[type]) {
151
+ return { success: false, error: `Unknown artifact type: "${type}". Expected pattern|recommendation|doctrine.` };
152
+ }
153
+ if (!id) return { success: false, error: 'id is required' };
154
+ if (!action) return { success: false, error: 'action is required' };
155
+ if (!actor) return { success: false, error: 'actor is required' };
156
+ if (!(action in ACTION_TARGET_STATUS)) {
157
+ return { success: false, error: `Unknown action: "${action}"` };
158
+ }
159
+
160
+ const cfg = ARTIFACT_TYPES[type];
161
+
162
+ // Load the artifact
163
+ const found = findArtifactById(rootDir, type, id);
164
+ if (!found) return { success: false, error: `${type} not found: ${id}` };
165
+
166
+ const artifact = found.data;
167
+ const fromStatus = artifact.status;
168
+
169
+ // Enforce reason requirement (reused REASON_REQUIRED)
170
+ if (REASON_REQUIRED.has(action) && !params.reason) {
171
+ return { success: false, error: `Action "${action}" requires a reason` };
172
+ }
173
+
174
+ // Enforce accepted-only actions (reused REQUIRES_ACCEPTED — invalidate)
175
+ if (REQUIRES_ACCEPTED.has(action) && fromStatus !== 'accepted') {
176
+ return { success: false, error: `Action "${action}" requires status "accepted", got "${fromStatus}"` };
177
+ }
178
+
179
+ // Enforce closed-only actions (reused REQUIRES_CLOSED — reopen)
180
+ if (REQUIRES_CLOSED.has(action) && fromStatus !== 'accepted' && fromStatus !== 'rejected') {
181
+ return { success: false, error: `Action "${action}" requires status "accepted" or "rejected", got "${fromStatus}"` };
182
+ }
183
+
184
+ // Determine the law's target status (reused ACTION_TARGET_STATUS)
185
+ const lawTarget = ACTION_TARGET_STATUS[action];
186
+
187
+ // Validate the transition under the reused finding law FIRST — an unlawful
188
+ // transition is refused with the same vocabulary the finding engine uses.
189
+ if (lawTarget !== null && lawTarget !== fromStatus) {
190
+ const transResult = validateTransition(fromStatus, lawTarget);
191
+ if (!transResult.valid) {
192
+ return { success: false, error: transResult.error };
193
+ }
194
+ }
195
+
196
+ // Map the law's target status onto the NARROWER artifact contract.
197
+ // The finding law uses `reviewed` as an intermediate state; artifact schemas
198
+ // do not permit it. Refuse honestly where the artifact cannot hold the state.
199
+ let toStatus;
200
+ if (action === 'invalidate') {
201
+ if (!cfg.hasInvalidatedStatus) {
202
+ return {
203
+ success: false,
204
+ error: `invalidate is not supported for ${type}: its schema has no "invalidated" status. ` +
205
+ `Use reject (with a reason) to retire an accepted ${type}.`
206
+ };
207
+ }
208
+ toStatus = 'invalidated';
209
+ } else if (lawTarget === 'reviewed') {
210
+ // `review` / `reopen` target the intermediate `reviewed` state, which no
211
+ // artifact schema permits. Refuse rather than write an invalid artifact.
212
+ return {
213
+ success: false,
214
+ error: `Action "${action}" targets status "reviewed", which the ${type} contract does not allow ` +
215
+ `(${type} status ∈ {${[...cfg.statuses].join(', ')}}). Use accept or reject instead.`
216
+ };
217
+ } else {
218
+ toStatus = lawTarget;
219
+ }
220
+
221
+ // Defensive: never persist a status the artifact schema forbids.
222
+ if (!cfg.statuses.has(toStatus)) {
223
+ return { success: false, error: `Computed status "${toStatus}" is not valid for ${type}.` };
224
+ }
225
+
226
+ const now = new Date().toISOString();
227
+
228
+ // Apply status + review metadata. `last_action` mirrors the finding engine's
229
+ // review block. For invalidate, `last_action: 'invalidate'` is what
230
+ // queryPatterns checks to exclude the artifact (advise/query.js).
231
+ artifact.status = toStatus;
232
+ artifact.review = {
233
+ reviewed_by: actor,
234
+ reviewed_at: now,
235
+ last_action: action,
236
+ ...(params.reason ? { decision_reason: params.reason } : {})
237
+ };
238
+ artifact.updated_at = now;
239
+
240
+ // Build the review event (carries artifact id + kind, back-compat).
241
+ const event = createEvent({
242
+ artifactId: id,
243
+ artifactKind: type,
244
+ actor,
245
+ action,
246
+ fromStatus,
247
+ toStatus,
248
+ reason: params.reason,
249
+ notes: params.notes
250
+ });
251
+
252
+ // Persist via the synthesis writer — which RE-VALIDATES the promoted artifact
253
+ // against its JSON Schema (fail-closed). The reset is the documented opt-in
254
+ // for a legitimate re-write of an id already touched in this process (the
255
+ // synthesis collision guard otherwise refuses the second write). B-003 —
256
+ // scope it to THIS id so a future batch reviewer doesn't disarm the guard for
257
+ // the other ids it has written this process.
258
+ try {
259
+ resetSeenArtifactWrite(rootDir, type, id);
260
+ cfg.write(rootDir, artifact);
261
+ } catch (err) {
262
+ return { success: false, error: err.message, code: err.code };
263
+ }
264
+
265
+ // Append the event only after the artifact persisted successfully.
266
+ appendEvent(rootDir, event);
267
+
268
+ return { success: true, artifact, event };
269
+ }
270
+
271
+ /**
272
+ * Get the artifact review queue: candidate artifacts needing operator attention.
273
+ * Mirrors `getReviewQueue` (review/review-engine.js). With `type` omitted,
274
+ * scans all three artifact families.
275
+ *
276
+ * @param {string} rootDir
277
+ * @param {'pattern'|'recommendation'|'doctrine'} [type]
278
+ * @returns {Array<{ data: object, path: string, type: string, queueReason: string }>}
279
+ */
280
+ export function getArtifactReviewQueue(rootDir, type) {
281
+ const types = type ? [type] : Object.keys(ARTIFACT_TYPES);
282
+ const queue = [];
283
+
284
+ for (const t of types) {
285
+ const cfg = ARTIFACT_TYPES[t];
286
+ if (!cfg) continue;
287
+ const { entries, skipped } = cfg.loadWithSkips(rootDir);
288
+ // B-001 — a torn artifact silently shrinks the review queue; name it so the
289
+ // operator knows the queue is partial rather than complete.
290
+ reportArtifactSkips(rootDir, t, skipped);
291
+ for (const entry of entries) {
292
+ const data = entry.data;
293
+ if (!data) continue;
294
+ if (data.status === 'candidate') {
295
+ queue.push({ data, path: entry.path, type: t, queueReason: 'Unreviewed candidate' });
296
+ }
297
+ }
298
+ }
299
+
300
+ return queue;
301
+ }