@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/README.md +54 -0
- package/cli.js +238 -17
- package/derive/ids.js +31 -3
- package/derive/write-findings.js +28 -0
- package/package.json +2 -2
- package/reader.js +74 -4
- package/review/event-log.js +72 -77
- package/review/review-artifacts.js +301 -0
- package/synthesis/apply-recommendation.js +259 -0
- package/synthesis/dedupe-artifacts.js +67 -0
- package/synthesis/index.js +4 -1
- package/synthesis/pattern-derivation.js +35 -4
- package/synthesis/write-artifacts.js +18 -0
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
|
-
|
|
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
|
|
package/review/event-log.js
CHANGED
|
@@ -1,36 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Append-only review event log.
|
|
3
3
|
*
|
|
4
|
-
* Events are stored
|
|
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 {
|
|
8
|
-
import { resolve
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
|
83
|
-
const dir = dirname(logPath);
|
|
116
|
+
const dir = getEventDir(rootDir);
|
|
84
117
|
mkdirSync(dir, { recursive: true });
|
|
85
118
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|