@dogfood-lab/findings 1.2.2 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogfood-lab/findings",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "description": "Finding contract spine for testing-os. Validates, reads, lists, and queries evidence-bound findings — the fourth contract alongside record, scenario, and policy.",
6
6
  "main": "index.js",
@@ -46,7 +46,7 @@
46
46
  "js-yaml": "^4.1.0"
47
47
  },
48
48
  "engines": {
49
- "node": ">=20"
49
+ "node": ">=22"
50
50
  },
51
51
  "author": "mcp-tool-shop",
52
52
  "license": "MIT",
package/reader.js CHANGED
@@ -1,156 +1,156 @@
1
- /**
2
- * Finding reader/lister.
3
- * Discovers findings from the filesystem, supports filtering and lookup.
4
- */
5
-
6
- import { readdirSync, existsSync, statSync } from 'node:fs';
7
- import { resolve, join, basename, extname } from 'node:path';
8
- import { parseFinding, validateFinding } from './validate.js';
9
-
10
- /**
11
- * Discover all .yaml finding files under a root directory.
12
- * Walks findings/<org>/<repo>/*.yaml
13
- *
14
- * @param {string} rootDir - The dogfood-labs repo root.
15
- * @returns {string[]} Array of absolute paths to finding files.
16
- */
17
- export function discoverFindings(rootDir) {
18
- const findingsDir = resolve(rootDir, 'findings');
19
- if (!existsSync(findingsDir)) return [];
20
-
21
- const paths = [];
22
-
23
- // Walk: findings/<org>/<repo>/*.yaml
24
- for (const org of listDirs(findingsDir)) {
25
- const orgDir = join(findingsDir, org);
26
- for (const repo of listDirs(orgDir)) {
27
- const repoDir = join(orgDir, repo);
28
- for (const file of readdirSync(repoDir)) {
29
- if (extname(file) === '.yaml') {
30
- paths.push(resolve(repoDir, file));
31
- }
32
- }
33
- }
34
- }
35
-
36
- return paths.sort();
37
- }
38
-
39
- /**
40
- * Discover finding files from fixtures directory.
41
- * @param {string} rootDir - The dogfood-labs repo root.
42
- * @param {'valid' | 'invalid'} kind - Which fixture set.
43
- * @returns {string[]} Array of absolute paths.
44
- */
45
- export function discoverFixtures(rootDir, kind) {
46
- const dir = resolve(rootDir, 'fixtures', 'findings', kind);
47
- if (!existsSync(dir)) return [];
48
-
49
- return readdirSync(dir)
50
- .filter(f => extname(f) === '.yaml')
51
- .map(f => resolve(dir, f))
52
- .sort();
53
- }
54
-
55
- /**
56
- * Load all findings from disk (real or fixtures).
57
- * Returns parsed + validated findings.
58
- *
59
- * @param {string} rootDir - The dogfood-labs repo root.
60
- * @param {{ fixtures?: boolean, fixtureKind?: 'valid' | 'invalid' }} opts
61
- * @returns {Array<{ path: string, data: object | null, valid: boolean, errors: Array }>}
62
- */
63
- export function loadFindings(rootDir, opts = {}) {
64
- const paths = opts.fixtures
65
- ? discoverFixtures(rootDir, opts.fixtureKind || 'valid')
66
- : discoverFindings(rootDir);
67
-
68
- return paths.map(filePath => {
69
- const { data, error } = parseFinding(filePath);
70
- if (error) {
71
- return { path: filePath, data: null, valid: false, errors: [{ path: '/', message: error }] };
72
- }
73
- const result = validateFinding(data);
74
- return { path: filePath, data, ...result };
75
- });
76
- }
77
-
78
- /**
79
- * Find a single finding by its finding_id.
80
- * Searches real findings first, then fixtures.
81
- *
82
- * @param {string} rootDir - The dogfood-labs repo root.
83
- * @param {string} findingId - The finding_id to look up.
84
- * @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
85
- */
86
- export function findById(rootDir, findingId) {
87
- // Search real findings
88
- for (const filePath of discoverFindings(rootDir)) {
89
- const { data } = parseFinding(filePath);
90
- if (data && data.finding_id === findingId) {
91
- const result = validateFinding(data);
92
- return { path: filePath, data, ...result };
93
- }
94
- }
95
-
96
- // Search valid fixtures
97
- for (const filePath of discoverFixtures(rootDir, 'valid')) {
98
- const { data } = parseFinding(filePath);
99
- if (data && data.finding_id === findingId) {
100
- const result = validateFinding(data);
101
- return { path: filePath, data, ...result };
102
- }
103
- }
104
-
105
- return null;
106
- }
107
-
108
- /**
109
- * Filter a list of loaded findings.
110
- *
111
- * @param {Array<{ data: object }>} findings - Loaded findings.
112
- * @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string }} filters
113
- * @returns {Array}
114
- */
115
- export function filterFindings(findings, filters = {}) {
116
- return findings.filter(f => {
117
- if (!f.data) return false;
118
- if (filters.repo && f.data.repo !== filters.repo) return false;
119
- if (filters.status && f.data.status !== filters.status) return false;
120
- if (filters.surface && f.data.product_surface !== filters.surface) return false;
121
- if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
122
- if (filters.transferScope && f.data.transfer_scope !== filters.transferScope) return false;
123
- return true;
124
- });
125
- }
126
-
127
- /**
128
- * Check for duplicate finding_ids across all findings.
129
- * @param {Array<{ data: object, path: string }>} findings
130
- * @returns {Array<{ findingId: string, paths: string[] }>}
131
- */
132
- export function findDuplicates(findings) {
133
- const seen = new Map();
134
- for (const f of findings) {
135
- if (!f.data || !f.data.finding_id) continue;
136
- const id = f.data.finding_id;
137
- if (!seen.has(id)) seen.set(id, []);
138
- seen.get(id).push(f.path);
139
- }
140
-
141
- return Array.from(seen.entries())
142
- .filter(([, paths]) => paths.length > 1)
143
- .map(([findingId, paths]) => ({ findingId, paths }));
144
- }
145
-
146
- /** List subdirectories of a directory. */
147
- function listDirs(dir) {
148
- if (!existsSync(dir)) return [];
149
- return readdirSync(dir).filter(name => {
150
- try {
151
- return statSync(join(dir, name)).isDirectory();
152
- } catch {
153
- return false;
154
- }
155
- });
156
- }
1
+ /**
2
+ * Finding reader/lister.
3
+ * Discovers findings from the filesystem, supports filtering and lookup.
4
+ */
5
+
6
+ import { readdirSync, existsSync, statSync } from 'node:fs';
7
+ import { resolve, join, basename, extname } from 'node:path';
8
+ import { parseFinding, validateFinding } from './validate.js';
9
+
10
+ /**
11
+ * Discover all .yaml finding files under a root directory.
12
+ * Walks findings/<org>/<repo>/*.yaml
13
+ *
14
+ * @param {string} rootDir - The dogfood-labs repo root.
15
+ * @returns {string[]} Array of absolute paths to finding files.
16
+ */
17
+ export function discoverFindings(rootDir) {
18
+ const findingsDir = resolve(rootDir, 'findings');
19
+ if (!existsSync(findingsDir)) return [];
20
+
21
+ const paths = [];
22
+
23
+ // Walk: findings/<org>/<repo>/*.yaml
24
+ for (const org of listDirs(findingsDir)) {
25
+ const orgDir = join(findingsDir, org);
26
+ for (const repo of listDirs(orgDir)) {
27
+ const repoDir = join(orgDir, repo);
28
+ for (const file of readdirSync(repoDir)) {
29
+ if (extname(file) === '.yaml') {
30
+ paths.push(resolve(repoDir, file));
31
+ }
32
+ }
33
+ }
34
+ }
35
+
36
+ return paths.sort();
37
+ }
38
+
39
+ /**
40
+ * Discover finding files from fixtures directory.
41
+ * @param {string} rootDir - The dogfood-labs repo root.
42
+ * @param {'valid' | 'invalid'} kind - Which fixture set.
43
+ * @returns {string[]} Array of absolute paths.
44
+ */
45
+ export function discoverFixtures(rootDir, kind) {
46
+ const dir = resolve(rootDir, 'fixtures', 'findings', kind);
47
+ if (!existsSync(dir)) return [];
48
+
49
+ return readdirSync(dir)
50
+ .filter(f => extname(f) === '.yaml')
51
+ .map(f => resolve(dir, f))
52
+ .sort();
53
+ }
54
+
55
+ /**
56
+ * Load all findings from disk (real or fixtures).
57
+ * Returns parsed + validated findings.
58
+ *
59
+ * @param {string} rootDir - The dogfood-labs repo root.
60
+ * @param {{ fixtures?: boolean, fixtureKind?: 'valid' | 'invalid' }} opts
61
+ * @returns {Array<{ path: string, data: object | null, valid: boolean, errors: Array }>}
62
+ */
63
+ export function loadFindings(rootDir, opts = {}) {
64
+ const paths = opts.fixtures
65
+ ? discoverFixtures(rootDir, opts.fixtureKind || 'valid')
66
+ : discoverFindings(rootDir);
67
+
68
+ return paths.map(filePath => {
69
+ const { data, error } = parseFinding(filePath);
70
+ if (error) {
71
+ return { path: filePath, data: null, valid: false, errors: [{ path: '/', message: error }] };
72
+ }
73
+ const result = validateFinding(data);
74
+ return { path: filePath, data, ...result };
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Find a single finding by its finding_id.
80
+ * Searches real findings first, then fixtures.
81
+ *
82
+ * @param {string} rootDir - The dogfood-labs repo root.
83
+ * @param {string} findingId - The finding_id to look up.
84
+ * @returns {{ path: string, data: object, valid: boolean, errors: Array } | null}
85
+ */
86
+ export function findById(rootDir, findingId) {
87
+ // Search real findings
88
+ for (const filePath of discoverFindings(rootDir)) {
89
+ const { data } = parseFinding(filePath);
90
+ if (data && data.finding_id === findingId) {
91
+ const result = validateFinding(data);
92
+ return { path: filePath, data, ...result };
93
+ }
94
+ }
95
+
96
+ // Search valid fixtures
97
+ for (const filePath of discoverFixtures(rootDir, 'valid')) {
98
+ const { data } = parseFinding(filePath);
99
+ if (data && data.finding_id === findingId) {
100
+ const result = validateFinding(data);
101
+ return { path: filePath, data, ...result };
102
+ }
103
+ }
104
+
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Filter a list of loaded findings.
110
+ *
111
+ * @param {Array<{ data: object }>} findings - Loaded findings.
112
+ * @param {{ repo?: string, status?: string, surface?: string, issueKind?: string, transferScope?: string }} filters
113
+ * @returns {Array}
114
+ */
115
+ export function filterFindings(findings, filters = {}) {
116
+ return findings.filter(f => {
117
+ if (!f.data) return false;
118
+ if (filters.repo && f.data.repo !== filters.repo) return false;
119
+ if (filters.status && f.data.status !== filters.status) return false;
120
+ if (filters.surface && f.data.product_surface !== filters.surface) return false;
121
+ if (filters.issueKind && f.data.issue_kind !== filters.issueKind) return false;
122
+ if (filters.transferScope && f.data.transfer_scope !== filters.transferScope) return false;
123
+ return true;
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Check for duplicate finding_ids across all findings.
129
+ * @param {Array<{ data: object, path: string }>} findings
130
+ * @returns {Array<{ findingId: string, paths: string[] }>}
131
+ */
132
+ export function findDuplicates(findings) {
133
+ const seen = new Map();
134
+ for (const f of findings) {
135
+ if (!f.data || !f.data.finding_id) continue;
136
+ const id = f.data.finding_id;
137
+ if (!seen.has(id)) seen.set(id, []);
138
+ seen.get(id).push(f.path);
139
+ }
140
+
141
+ return Array.from(seen.entries())
142
+ .filter(([, paths]) => paths.length > 1)
143
+ .map(([findingId, paths]) => ({ findingId, paths }));
144
+ }
145
+
146
+ /** List subdirectories of a directory. */
147
+ function listDirs(dir) {
148
+ if (!existsSync(dir)) return [];
149
+ return readdirSync(dir).filter(name => {
150
+ try {
151
+ return statSync(join(dir, name)).isDirectory();
152
+ } catch {
153
+ return false;
154
+ }
155
+ });
156
+ }
package/review/index.js CHANGED
@@ -1,6 +1,6 @@
1
- /**
2
- * Review system exports.
3
- */
4
- export { isLawfulTransition, validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED } from './transitions.js';
5
- export { createEvent, appendEvent, getEventsForFinding, getAllEvents, getLogPath, generateEventId } from './event-log.js';
6
- export { performAction, performMerge, getReviewQueue } from './review-engine.js';
1
+ /**
2
+ * Review system exports.
3
+ */
4
+ export { isLawfulTransition, validateTransition, ACTION_TARGET_STATUS, REASON_REQUIRED } from './transitions.js';
5
+ export { createEvent, appendEvent, getEventsForFinding, getAllEvents, getLogPath, generateEventId } from './event-log.js';
6
+ export { performAction, performMerge, getReviewQueue } from './review-engine.js';
@@ -1,79 +1,79 @@
1
- /**
2
- * Status transition law for findings.
3
- *
4
- * Lawful transitions:
5
- * candidate -> reviewed, accepted, rejected
6
- * reviewed -> accepted, rejected
7
- * accepted -> reviewed (via reopen/invalidate only)
8
- * accepted -> rejected (via invalidation/reversal only)
9
- * rejected -> reviewed (via reopen only)
10
- *
11
- * Forbidden:
12
- * rejected -> candidate (no rewinding to machine output)
13
- * any status -> candidate (except initial creation)
14
- */
15
-
16
- const TRANSITIONS = {
17
- candidate: new Set(['reviewed', 'accepted', 'rejected']),
18
- reviewed: new Set(['accepted', 'rejected']),
19
- accepted: new Set(['reviewed', 'rejected']),
20
- rejected: new Set(['reviewed'])
21
- };
22
-
23
- /**
24
- * Check if a status transition is lawful.
25
- * @param {string} from - Current status.
26
- * @param {string} to - Desired status.
27
- * @returns {boolean}
28
- */
29
- export function isLawfulTransition(from, to) {
30
- const allowed = TRANSITIONS[from];
31
- if (!allowed) return false;
32
- return allowed.has(to);
33
- }
34
-
35
- /**
36
- * Validate a transition and return error if invalid.
37
- * @param {string} from
38
- * @param {string} to
39
- * @returns {{ valid: boolean, error?: string }}
40
- */
41
- export function validateTransition(from, to) {
42
- if (!TRANSITIONS[from]) {
43
- return { valid: false, error: `Unknown status: "${from}"` };
44
- }
45
- if (!isLawfulTransition(from, to)) {
46
- const allowed = [...TRANSITIONS[from]].join(', ');
47
- return { valid: false, error: `Cannot transition from "${from}" to "${to}". Allowed: ${allowed}` };
48
- }
49
- return { valid: true };
50
- }
51
-
52
- /**
53
- * Map review actions to their target statuses.
54
- */
55
- export const ACTION_TARGET_STATUS = {
56
- review: 'reviewed',
57
- accept: 'accepted',
58
- reject: 'rejected',
59
- edit: null, // edit preserves current status
60
- merge: 'rejected', // merged sources become rejected (merged_into_canonical)
61
- reopen: 'reviewed',
62
- invalidate: 'reviewed', // invalidated accepted → back to reviewed with invalidation metadata
63
- supersede: 'rejected' // superseded finding becomes rejected
64
- };
65
-
66
- /**
67
- * Actions that require a reason.
68
- */
69
- export const REASON_REQUIRED = new Set(['reject', 'invalidate', 'merge', 'supersede']);
70
-
71
- /**
72
- * Actions that require from_status to be accepted.
73
- */
74
- export const REQUIRES_ACCEPTED = new Set(['invalidate']);
75
-
76
- /**
77
- * Actions that require from_status to be accepted or rejected.
78
- */
79
- export const REQUIRES_CLOSED = new Set(['reopen']);
1
+ /**
2
+ * Status transition law for findings.
3
+ *
4
+ * Lawful transitions:
5
+ * candidate -> reviewed, accepted, rejected
6
+ * reviewed -> accepted, rejected
7
+ * accepted -> reviewed (via reopen/invalidate only)
8
+ * accepted -> rejected (via invalidation/reversal only)
9
+ * rejected -> reviewed (via reopen only)
10
+ *
11
+ * Forbidden:
12
+ * rejected -> candidate (no rewinding to machine output)
13
+ * any status -> candidate (except initial creation)
14
+ */
15
+
16
+ const TRANSITIONS = {
17
+ candidate: new Set(['reviewed', 'accepted', 'rejected']),
18
+ reviewed: new Set(['accepted', 'rejected']),
19
+ accepted: new Set(['reviewed', 'rejected']),
20
+ rejected: new Set(['reviewed'])
21
+ };
22
+
23
+ /**
24
+ * Check if a status transition is lawful.
25
+ * @param {string} from - Current status.
26
+ * @param {string} to - Desired status.
27
+ * @returns {boolean}
28
+ */
29
+ export function isLawfulTransition(from, to) {
30
+ const allowed = TRANSITIONS[from];
31
+ if (!allowed) return false;
32
+ return allowed.has(to);
33
+ }
34
+
35
+ /**
36
+ * Validate a transition and return error if invalid.
37
+ * @param {string} from
38
+ * @param {string} to
39
+ * @returns {{ valid: boolean, error?: string }}
40
+ */
41
+ export function validateTransition(from, to) {
42
+ if (!TRANSITIONS[from]) {
43
+ return { valid: false, error: `Unknown status: "${from}"` };
44
+ }
45
+ if (!isLawfulTransition(from, to)) {
46
+ const allowed = [...TRANSITIONS[from]].join(', ');
47
+ return { valid: false, error: `Cannot transition from "${from}" to "${to}". Allowed: ${allowed}` };
48
+ }
49
+ return { valid: true };
50
+ }
51
+
52
+ /**
53
+ * Map review actions to their target statuses.
54
+ */
55
+ export const ACTION_TARGET_STATUS = {
56
+ review: 'reviewed',
57
+ accept: 'accepted',
58
+ reject: 'rejected',
59
+ edit: null, // edit preserves current status
60
+ merge: 'rejected', // merged sources become rejected (merged_into_canonical)
61
+ reopen: 'reviewed',
62
+ invalidate: 'reviewed', // invalidated accepted → back to reviewed with invalidation metadata
63
+ supersede: 'rejected' // superseded finding becomes rejected
64
+ };
65
+
66
+ /**
67
+ * Actions that require a reason.
68
+ */
69
+ export const REASON_REQUIRED = new Set(['reject', 'invalidate', 'merge', 'supersede']);
70
+
71
+ /**
72
+ * Actions that require from_status to be accepted.
73
+ */
74
+ export const REQUIRES_ACCEPTED = new Set(['invalidate']);
75
+
76
+ /**
77
+ * Actions that require from_status to be accepted or rejected.
78
+ */
79
+ export const REQUIRES_CLOSED = new Set(['reopen']);