@pareto4/pp-core 1.19.0 → 1.20.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.
Files changed (79) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-phase-researcher.md +10 -0
  3. package/agents/pp-planner.md +15 -0
  4. package/agents/pp-vault-reviewer.md +2 -0
  5. package/agents/pp-verifier.md +12 -8
  6. package/commands/pp-discovery.md +3 -2
  7. package/gemini-extension.json +1 -1
  8. package/package.json +6 -3
  9. package/pareto-skills/_shared/pp-claude-md-protocol.md +1 -1
  10. package/pareto-skills/pp-project-setup/SKILL.md +18 -343
  11. package/pp-core/bin/lib/capability-registry.cjs +1 -1
  12. package/pp-core/bin/lib/discovery-access.cjs +276 -0
  13. package/pp-core/bin/lib/discovery-charter.cjs +664 -0
  14. package/pp-core/bin/lib/discovery-deviations.cjs +264 -0
  15. package/pp-core/bin/lib/discovery-entities.cjs +323 -0
  16. package/pp-core/bin/lib/discovery-for-phase.cjs +628 -0
  17. package/pp-core/bin/lib/discovery-integrations.cjs +286 -0
  18. package/pp-core/bin/lib/discovery-migration.cjs +246 -0
  19. package/pp-core/bin/lib/discovery-phases.cjs +233 -0
  20. package/pp-core/bin/lib/discovery-processes.cjs +355 -0
  21. package/pp-core/bin/lib/discovery-research-track.cjs +5 -4
  22. package/pp-core/bin/lib/discovery-rollup.cjs +8 -7
  23. package/pp-core/bin/lib/discovery-rules.cjs +216 -0
  24. package/pp-core/bin/lib/discovery-screens.cjs +1 -1
  25. package/pp-core/bin/lib/discovery-session-check.cjs +264 -0
  26. package/pp-core/bin/lib/discovery-stories.cjs +273 -0
  27. package/pp-core/bin/lib/discovery-sweep.cjs +1 -1
  28. package/pp-core/bin/lib/discovery-table.cjs +55 -0
  29. package/pp-core/bin/lib/io.cjs +22 -0
  30. package/pp-core/bin/lib/vault-standards-review.cjs +50 -2
  31. package/pp-core/bin/pp-tools.cjs +143 -1
  32. package/pp-core/references/discovery-access.md +112 -0
  33. package/pp-core/references/discovery-build-consumption.md +256 -0
  34. package/pp-core/references/discovery-charter.md +127 -0
  35. package/pp-core/references/discovery-data-model.md +145 -0
  36. package/pp-core/references/discovery-id-conventions.md +50 -14
  37. package/pp-core/references/discovery-integrations.md +151 -0
  38. package/pp-core/references/discovery-processes.md +190 -0
  39. package/pp-core/references/discovery-rebaseline.md +2 -2
  40. package/pp-core/references/discovery-research-track.md +5 -6
  41. package/pp-core/references/discovery-session-output.md +70 -0
  42. package/pp-core/references/discovery-standards.md +107 -0
  43. package/pp-core/references/discovery-traceability.md +2 -2
  44. package/pp-core/references/planner-discovery-extract.md +35 -0
  45. package/pp-core/references/verifier-discovery-conformance.md +36 -0
  46. package/pp-core/templates/project-charter.md +161 -100
  47. package/pp-core/templates/project.md +3 -3
  48. package/pp-core/templates/requirements.md +18 -0
  49. package/pp-core/templates/verification-report.md +16 -0
  50. package/pp-core/workflows/discovery-sprint/charter.md +114 -0
  51. package/pp-core/workflows/discovery-sprint/prefill.md +1 -1
  52. package/pp-core/workflows/discovery-sprint/prototype.md +4 -4
  53. package/pp-core/workflows/discovery-sprint/session-13-gates.md +10 -0
  54. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +5 -2
  55. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +27 -5
  56. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +56 -31
  57. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +1 -1
  58. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +42 -19
  59. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +27 -12
  60. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +41 -23
  61. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +50 -23
  62. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +26 -11
  63. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +28 -9
  64. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +21 -0
  65. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +24 -9
  66. package/pp-core/workflows/discovery-sprint.md +12 -15
  67. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +80 -29
  68. package/pp-core/workflows/discovery-to-build/steps/{screen-inventory-gate.md → handoff-gates.md} +111 -3
  69. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +12 -9
  70. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +27 -26
  71. package/pp-core/workflows/discovery-to-build.md +70 -34
  72. package/pp-core/workflows/discuss-phase/discovery.md +83 -0
  73. package/pp-core/workflows/discuss-phase/templates/context.md +4 -0
  74. package/pp-core/workflows/discuss-phase.md +10 -7
  75. package/pp-core/workflows/new-project.md +3 -3
  76. package/pp-core/workflows/plan-phase/steps/discovery-extract.md +41 -0
  77. package/pp-core/workflows/plan-phase.md +15 -10
  78. package/pp-core/workflows/vault-review.md +5 -0
  79. package/pp-core/workflows/discovery-sprint/templates/context-template.md +0 -272
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ /**
3
+ * Discovery deviations check (Discovery Sprint improvement plan, Batch 3).
4
+ *
5
+ * `.planning/discovery/_deviations.md` is the register of agreed departures
6
+ * from the client's standards: `DEV-nn`, the vault standard deviated from
7
+ * (`[vault: <path>]`), the choice made instead, the reason, whether the choice
8
+ * is easy or hard to reverse (the vault's own distinction — a hard one blocks
9
+ * the affected work until an architect signs), who approves it, the decision
10
+ * that recorded it and its status. Sessions 1 and 4–10 write it as their
11
+ * Standards Applied rows surface a deviation; the charter § 7 copies it; the
12
+ * vault reviewer reads it so an approved deviation is reported as "deviation
13
+ * on file" and never as a violation. This module checks the structure:
14
+ *
15
+ * - the `## Deviations` table exists with the template's columns;
16
+ * - `DEV-` IDs are well-formed, unique, contiguous;
17
+ * - `Standard` cites at least one `[vault: <path>]`, each path safe and,
18
+ * when the vault cache is on disk, present in it;
19
+ * - `Reversible` and `Status` come from their closed vocabularies;
20
+ * - `Approver` is one the Session 1 Approval Matrix names, when that table
21
+ * is on file with rows;
22
+ * - `Decision` is `—` or a decision ID, and an `Approved` or `Rejected` row
23
+ * carries one;
24
+ * - `--require-approved`: a hard-to-reverse deviation that is still
25
+ * `Proposed` fails on its own exit identity, so `/pp-discovery-to-build`
26
+ * can hard-block on it while Sessions 8 and 13 only report.
27
+ *
28
+ * Posture (D-43/D-53): reports at Sessions 8 and 13; hard-blocks at the
29
+ * handoff. Absent artifact → exit 0.
30
+ */
31
+ var __importDefault = (this && this.__importDefault) || function (mod) {
32
+ return (mod && mod.__esModule) ? mod : { "default": mod };
33
+ };
34
+ const node_fs_1 = __importDefault(require("node:fs"));
35
+ const node_path_1 = __importDefault(require("node:path"));
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
+ const io = require("./io.cjs");
38
+ const { output, error, ERROR_REASON } = io;
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const table = require("./discovery-table.cjs");
41
+ // The vault-index leaf owns the vault path rule (14-03 anti-drift: one owner in src/).
42
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
43
+ const vaultIndex = require("./vault-index.cjs");
44
+ // ─── Constants ────────────────────────────────────────────────────────────────
45
+ const DISCOVERY_DIR = '.planning/discovery';
46
+ const DEVIATIONS_FILE = `${DISCOVERY_DIR}/_deviations.md`;
47
+ const SESSION_01_FILE = `${DISCOVERY_DIR}/session-01.md`;
48
+ const DEVIATIONS_SECTION = 'Deviations';
49
+ const DEVIATIONS_HEADER = Object.freeze(['ID', 'Standard', 'Choice', 'Reason', 'Reversible', 'Approver', 'Decision', 'Status']);
50
+ const DEV_RE = /^DEV-(\d{2})$/;
51
+ const REVERSIBLE = Object.freeze(['easy', 'hard']);
52
+ const STATUSES = Object.freeze(['Proposed', 'Approved', 'Rejected', 'Superseded']);
53
+ const DECIDED_STATUSES = Object.freeze(['Approved', 'Rejected']);
54
+ const DECISION_ID_RE = /^D-(\d{2}|P\d{2})-\d+$/;
55
+ const VAULT_CITE_RE = /\[vault:\s*([^\]]+?)\s*\]/g;
56
+ const PENDING = '—';
57
+ const APPROVAL_MATRIX_LABEL = 'Approval Matrix';
58
+ const APPROVER_COLUMN = 'Approver';
59
+ const USAGE = 'Usage: pp-tools discovery deviations-check [--require-approved]';
60
+ function readIfPresent(abs) {
61
+ if (!node_fs_1.default.existsSync(abs))
62
+ return { kind: 'absent' };
63
+ try {
64
+ return { kind: 'ok', content: node_fs_1.default.readFileSync(abs, 'utf-8') };
65
+ }
66
+ catch {
67
+ return { kind: 'unreadable' };
68
+ }
69
+ }
70
+ function sameHeader(actual, expected) {
71
+ return actual.length === expected.length && actual.every((c, i) => c.trim().toLowerCase() === expected[i].toLowerCase());
72
+ }
73
+ function splitList(cell) {
74
+ return cell.split(',').map((t) => t.trim()).filter(Boolean);
75
+ }
76
+ /**
77
+ * A vault path a deviation may cite: the vault index leaf's path rule (relative,
78
+ * no traversal, no dot or empty segment, display-safe) plus the sessions' own
79
+ * `.md` requirement (`workflows/discovery-sprint/sessions/04-ux-design.md`).
80
+ */
81
+ function isSafeStandardPath(p) {
82
+ return vaultIndex.isSafeVaultPath(p) && p.endsWith('.md');
83
+ }
84
+ /** The Approver tokens of Session 1's Approval Matrix, by header name; null when absent or empty. */
85
+ function approversFrom(content) {
86
+ const lines = content.split(/\r?\n/);
87
+ const norm = (s) => s.replace(/^#+\s*/, '').replace(/\*\*/g, '').trim();
88
+ const at = lines.findIndex((l) => norm(l) === APPROVAL_MATRIX_LABEL);
89
+ if (at === -1)
90
+ return null;
91
+ const body = [];
92
+ for (let i = at + 1; i < lines.length; i += 1) {
93
+ if (/^#{1,3}\s/.test(lines[i]) || /^\*\*[^*]+\*\*$/.test(lines[i].trim()))
94
+ break;
95
+ body.push({ n: i + 1, text: lines[i] });
96
+ }
97
+ const tables = table.parseTables(body);
98
+ if (tables.length === 0)
99
+ return null;
100
+ const col = tables[0].header.findIndex((h) => h.trim().toLowerCase() === APPROVER_COLUMN.toLowerCase());
101
+ if (col === -1)
102
+ return null;
103
+ const out = new Set();
104
+ for (const r of tables[0].rows)
105
+ for (const t of splitList(r.cells[col] ?? ''))
106
+ out.add(t);
107
+ return out.size === 0 ? null : out;
108
+ }
109
+ /** The vault cache directory on disk, through the vault's own resolver when it loads; null when there is no cache. */
110
+ function vaultCacheDirOf(cwd) {
111
+ let dir = node_path_1.default.join(cwd, '.planning', 'vault');
112
+ try {
113
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
114
+ const vault = require('./vault.cjs');
115
+ if (typeof vault.resolveVaultCacheDir === 'function')
116
+ dir = vault.resolveVaultCacheDir(cwd).dir;
117
+ }
118
+ catch {
119
+ /* the local cache path stands */
120
+ }
121
+ try {
122
+ return node_fs_1.default.statSync(dir).isDirectory() ? dir : null;
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ }
128
+ // ─── Check ────────────────────────────────────────────────────────────────────
129
+ function checkDeviations(content, sources, requireApproved = false) {
130
+ const violations = [];
131
+ const empty = { deviations: 0, approved: 0, unapproved_hard: 0 };
132
+ const section = table.splitSections(content.split(/\r?\n/), 2).find((s) => s.heading === DEVIATIONS_SECTION);
133
+ if (!section)
134
+ return { ok: false, ...empty, violations: [{ artifact: DEVIATIONS_FILE, reason: 'missing_section', id: DEVIATIONS_SECTION }] };
135
+ const tables = table.parseTables(section.body);
136
+ if (tables.length === 0)
137
+ return { ok: false, ...empty, violations: [{ artifact: DEVIATIONS_FILE, reason: 'missing_section', line: section.line, id: DEVIATIONS_SECTION }] };
138
+ if (!sameHeader(tables[0].header, DEVIATIONS_HEADER)) {
139
+ return { ok: false, ...empty, violations: [{ artifact: DEVIATIONS_FILE, reason: 'bad_table_shape', line: tables[0].line, id: DEVIATIONS_SECTION }] };
140
+ }
141
+ const ids = new Set();
142
+ const seqs = [];
143
+ let approved = 0;
144
+ let unapprovedHard = 0;
145
+ for (const row of tables[0].rows) {
146
+ const [id, standard, choice, reason, reversible, approver, decision, status] = row.cells.map((c) => c.trim());
147
+ const line = row.line;
148
+ const m = DEV_RE.exec(id ?? '');
149
+ if (!m)
150
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'bad_id', line });
151
+ else if (ids.has(id))
152
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'duplicate_id', line, id });
153
+ else {
154
+ ids.add(id);
155
+ seqs.push(Number(m[1]));
156
+ }
157
+ const cites = [...(standard ?? '').matchAll(VAULT_CITE_RE)].map((c) => c[1]);
158
+ if (cites.length === 0)
159
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'bad_standard', line });
160
+ for (const p of cites) {
161
+ if (!isSafeStandardPath(p))
162
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'bad_standard', line, id: p.slice(0, 80) });
163
+ else if (sources.vaultCacheDir !== null && !node_fs_1.default.existsSync(node_path_1.default.join(sources.vaultCacheDir, p)))
164
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'unknown_standard', line, id: p });
165
+ }
166
+ if ((choice ?? '') === '')
167
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'empty_cell', line, id: 'Choice' });
168
+ if ((reason ?? '') === '')
169
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'empty_cell', line, id: 'Reason' });
170
+ const rev = reversible ?? '';
171
+ if (!REVERSIBLE.includes(rev))
172
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'bad_reversible', line });
173
+ if ((approver ?? '') === '')
174
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'empty_cell', line, id: 'Approver' });
175
+ else if (sources.approvers !== null && !sources.approvers.has(approver))
176
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'unknown_approver', line });
177
+ const dec = decision ?? '';
178
+ const st = status ?? '';
179
+ if (dec !== PENDING && !DECISION_ID_RE.test(dec))
180
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'bad_decision', line });
181
+ if (!STATUSES.includes(st))
182
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'bad_status', line });
183
+ else {
184
+ if (DECIDED_STATUSES.includes(st) && dec === PENDING)
185
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'decision_missing', line, id: m ? id : undefined });
186
+ if (st === 'Approved')
187
+ approved += 1;
188
+ if (st === 'Proposed' && rev === 'hard') {
189
+ unapprovedHard += 1;
190
+ if (requireApproved)
191
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'hard_deviation_unapproved', line, id: m ? id : undefined });
192
+ }
193
+ }
194
+ }
195
+ const max = table.boundedSequenceMax(seqs);
196
+ if (max !== null) {
197
+ const have = new Set(seqs);
198
+ for (let n = 1; n <= max; n += 1) {
199
+ if (!have.has(n))
200
+ violations.push({ artifact: DEVIATIONS_FILE, reason: 'sequence_gap', id: `DEV-${String(n).padStart(2, '0')}` });
201
+ }
202
+ }
203
+ return { ok: violations.length === 0, deviations: ids.size, approved, unapproved_hard: unapprovedHard, violations };
204
+ }
205
+ function checkProjectDeviations(cwd, requireApproved = false) {
206
+ const read = readIfPresent(node_path_1.default.join(cwd, DEVIATIONS_FILE));
207
+ if (read.kind === 'absent')
208
+ return null;
209
+ if (read.kind === 'unreadable') {
210
+ return { ok: false, file: DEVIATIONS_FILE, deviations: 0, approved: 0, unapproved_hard: 0, violations: [{ artifact: DEVIATIONS_FILE, reason: 'artifact_unreadable' }] };
211
+ }
212
+ const extra = [];
213
+ const sources = { approvers: null, vaultCacheDir: vaultCacheDirOf(cwd) };
214
+ const s1 = readIfPresent(node_path_1.default.join(cwd, SESSION_01_FILE));
215
+ if (s1.kind === 'unreadable')
216
+ extra.push({ artifact: SESSION_01_FILE, reason: 'artifact_unreadable' });
217
+ if (s1.kind === 'ok')
218
+ sources.approvers = approversFrom(s1.content);
219
+ const result = checkDeviations(read.content, sources, requireApproved);
220
+ result.violations.push(...extra);
221
+ result.ok = result.violations.length === 0;
222
+ return { file: DEVIATIONS_FILE, ...result };
223
+ }
224
+ function formatViolation(v) {
225
+ const where = v.line !== undefined ? `:${v.line}` : '';
226
+ const what = v.id !== undefined ? ` (${v.id})` : '';
227
+ return ` - ${v.artifact}${where}: ${v.reason}${what}`;
228
+ }
229
+ // ─── CLI adapter ──────────────────────────────────────────────────────────────
230
+ /** `pp-tools discovery deviations-check [--require-approved]` (dotted `query discovery.deviations-check`). Absent artifact → exit 0, `present: false`. */
231
+ function cmdDiscoveryDeviationsCheck(cwd, args, raw) {
232
+ const rest = args.slice(2);
233
+ const requireApproved = rest.includes('--require-approved');
234
+ if (rest.some((a) => a !== '--require-approved')) {
235
+ error(USAGE, ERROR_REASON.USAGE);
236
+ return; // unreachable
237
+ }
238
+ const result = checkProjectDeviations(cwd, requireApproved);
239
+ if (result === null) {
240
+ output({ ok: true, present: false, file: DEVIATIONS_FILE }, raw);
241
+ return;
242
+ }
243
+ const payload = { ok: result.ok, present: true, file: result.file, deviations: result.deviations, approved: result.approved, unapproved_hard: result.unapproved_hard };
244
+ if (!result.ok) {
245
+ const onlyUnapproved = result.violations.every((v) => v.reason === 'hard_deviation_unapproved');
246
+ output({ ...payload, violations: result.violations.length }, raw);
247
+ error(`discovery deviations-check: ${result.violations.length} violation(s):\n` + result.violations.map(formatViolation).join('\n'), onlyUnapproved ? ERROR_REASON.DISCOVERY_DEVIATIONS_UNAPPROVED : ERROR_REASON.DISCOVERY_DEVIATIONS_CHECK_FAILED);
248
+ return; // unreachable
249
+ }
250
+ output(payload, raw);
251
+ }
252
+ module.exports = {
253
+ DEVIATIONS_FILE,
254
+ DEVIATIONS_HEADER,
255
+ REVERSIBLE,
256
+ STATUSES,
257
+ DEVIATIONS_CHECK_FAILED: ERROR_REASON.DISCOVERY_DEVIATIONS_CHECK_FAILED,
258
+ DEVIATIONS_UNAPPROVED: ERROR_REASON.DISCOVERY_DEVIATIONS_UNAPPROVED,
259
+ isSafeStandardPath,
260
+ approversFrom,
261
+ checkDeviations,
262
+ checkProjectDeviations,
263
+ cmdDiscoveryDeviationsCheck,
264
+ };
@@ -0,0 +1,323 @@
1
+ "use strict";
2
+ /**
3
+ * Discovery data-model check (Discovery Sprint improvement plan, Batch 2).
4
+ *
5
+ * `.planning/discovery/_entities.md` is the one data model a build starts
6
+ * from: every entity by name (entities are named, never numbered — Batch 2
7
+ * decision), its identifier, whether it has a status lifecycle, its
8
+ * classification in the vault's four levels, its retention, its owner module
9
+ * and its source; one attribute table per entity; relationships with Mermaid
10
+ * cardinality; lifecycle states and transitions. Session 2 seeds it from the
11
+ * Data Entity Map, Session 3's step matrices name the objects each step
12
+ * touches, Session 5 consolidates it, Session 6 writes classification and
13
+ * retention. This module checks the structure — never the judgement:
14
+ *
15
+ * - the four sections exist, each table has the template's columns;
16
+ * - entity names are PascalCase tokens, unique; `Status lifecycle` and
17
+ * `Classification` are inside their closed vocabularies;
18
+ * - every attribute heading, relationship end and lifecycle row names an
19
+ * entity the `## Entities` table has; `Required` is `yes` or `no`;
20
+ * - every cardinality is a Mermaid `erDiagram` cardinality, so the ERD the
21
+ * handoff draws needs no default;
22
+ * - a lifecycle flag of `yes` has states, and states have a flag;
23
+ * - every data object a step matrix in `_processes.md` touches (`Task [C]`)
24
+ * is an entity;
25
+ * - with `--require-complete`: no `—` is left in Identifier, Status
26
+ * lifecycle, Classification or Retention, and every entity has at least
27
+ * one attribute. Before Session 5 those cells are allowed to be pending.
28
+ *
29
+ * Posture (D-43/D-53): the check reports at Sessions 8 and 13 and hard-blocks
30
+ * at `/pp-discovery-to-build`, where `--require-complete` is added. Absent
31
+ * artifact → exit 0 (an engagement that predates the data model).
32
+ *
33
+ * Content-free contract: output names the artifact, line numbers, entity
34
+ * names (validated tokens), section keys and reason codes — never cell prose.
35
+ * No positional path: convention paths resolve against the working directory.
36
+ */
37
+ var __importDefault = (this && this.__importDefault) || function (mod) {
38
+ return (mod && mod.__esModule) ? mod : { "default": mod };
39
+ };
40
+ const node_fs_1 = __importDefault(require("node:fs"));
41
+ const node_path_1 = __importDefault(require("node:path"));
42
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
43
+ const io = require("./io.cjs");
44
+ const { output, error, ERROR_REASON } = io;
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const table = require("./discovery-table.cjs");
47
+ // ─── Constants ────────────────────────────────────────────────────────────────
48
+ const DISCOVERY_DIR = '.planning/discovery';
49
+ const ENTITIES_FILE = `${DISCOVERY_DIR}/_entities.md`;
50
+ const PROCESSES_FILE = `${DISCOVERY_DIR}/_processes.md`;
51
+ const SECTIONS = Object.freeze({ entities: 'Entities', attributes: 'Attributes', relationships: 'Relationships', lifecycles: 'Lifecycles' });
52
+ const ENTITIES_HEADER = Object.freeze(['Entity', 'Identifier', 'Status lifecycle', 'Classification', 'Retention', 'Owner module', 'Source']);
53
+ const ATTRIBUTES_HEADER = Object.freeze(['Attribute', 'Type', 'Required', 'Bounds', 'Notes']);
54
+ const RELATIONSHIPS_HEADER = Object.freeze(['From', 'Cardinality', 'To', 'Name']);
55
+ const LIFECYCLES_HEADER = Object.freeze(['Entity', 'State', 'Transition', 'Allowed by']);
56
+ /** Entities are named, never numbered: one PascalCase token, cited exactly. */
57
+ const ENTITY_NAME_RE = /^[A-Z][A-Za-z0-9]*$/;
58
+ const PENDING = '—';
59
+ const LIFECYCLE_VALUES = Object.freeze(['none', 'yes', PENDING]);
60
+ /** The vault's four levels (`security/compliance.md` § Information handling). */
61
+ const CLASSIFICATION_LEVELS = Object.freeze(['Public', 'Internal', 'Confidential', 'Restricted']);
62
+ const REQUIRED_VALUES = Object.freeze(['yes', 'no', PENDING]);
63
+ /** Mermaid `erDiagram` cardinality: a left end, an identifying (`--`) or non-identifying (`..`) line, a right end. */
64
+ const CARDINALITY_RE = /^(\|\||\|o|\}o|\}\|)(--|\.\.)(\|\||o\||o\{|\|\{)$/;
65
+ /** A step-matrix data object: `Task [C]`, `Membership [C/R/U]`. */
66
+ const DATA_OBJECT_RE = /^([A-Z][A-Za-z0-9]*) \[([CRUD](?:\/[CRUD])*)\]$/;
67
+ const STEPS_DATA_OBJECTS_COLUMN = 'Data objects';
68
+ const USAGE = 'Usage: pp-tools discovery entity-check [--require-complete]';
69
+ function readIfPresent(abs) {
70
+ if (!node_fs_1.default.existsSync(abs))
71
+ return { kind: 'absent' };
72
+ try {
73
+ return { kind: 'ok', content: node_fs_1.default.readFileSync(abs, 'utf-8') };
74
+ }
75
+ catch {
76
+ return { kind: 'unreadable' };
77
+ }
78
+ }
79
+ function sameHeader(actual, expected) {
80
+ return actual.length === expected.length && actual.every((c, i) => c.trim().toLowerCase() === expected[i].toLowerCase());
81
+ }
82
+ /** The first table of a `##` section, with its header verified; null when the section or table is absent. */
83
+ function sectionTable(sections, heading, expected, violations) {
84
+ const section = sections.find((s) => s.heading === heading);
85
+ if (!section) {
86
+ violations.push({ artifact: ENTITIES_FILE, reason: 'missing_section', id: heading });
87
+ return null;
88
+ }
89
+ const tables = table.parseTables(section.body);
90
+ if (tables.length === 0) {
91
+ violations.push({ artifact: ENTITIES_FILE, reason: 'missing_section', line: section.line, id: heading });
92
+ return null;
93
+ }
94
+ if (!sameHeader(tables[0].header, expected)) {
95
+ violations.push({ artifact: ENTITIES_FILE, reason: 'bad_table_shape', line: tables[0].line, id: heading });
96
+ return null;
97
+ }
98
+ return tables[0];
99
+ }
100
+ // ─── Check ────────────────────────────────────────────────────────────────────
101
+ /** Check `_entities.md` text; `stepObjects` are the `Data objects` cells of `_processes.md` step tables. */
102
+ function checkEntities(content, stepObjects = [], requireComplete = false) {
103
+ const violations = [];
104
+ const lines = content.split(/\r?\n/);
105
+ const sections = table.splitSections(lines, 2);
106
+ // 1 ─ Entities
107
+ const entities = new Map();
108
+ const entitiesTable = sectionTable(sections, SECTIONS.entities, ENTITIES_HEADER, violations);
109
+ if (entitiesTable) {
110
+ for (const row of entitiesTable.rows) {
111
+ const [name, identifier, lifecycle, classification, retention] = row.cells.map((c) => c.trim());
112
+ if (!ENTITY_NAME_RE.test(name ?? '')) {
113
+ violations.push({ artifact: ENTITIES_FILE, reason: 'bad_entity_name', line: row.line });
114
+ continue;
115
+ }
116
+ if (entities.has(name)) {
117
+ violations.push({ artifact: ENTITIES_FILE, reason: 'duplicate_entity', line: row.line, id: name });
118
+ continue;
119
+ }
120
+ if (!LIFECYCLE_VALUES.includes(lifecycle ?? ''))
121
+ violations.push({ artifact: ENTITIES_FILE, reason: 'bad_lifecycle_flag', line: row.line, id: name });
122
+ if ((classification ?? '') !== PENDING && !CLASSIFICATION_LEVELS.includes(classification ?? '')) {
123
+ violations.push({ artifact: ENTITIES_FILE, reason: 'bad_classification', line: row.line, id: name });
124
+ }
125
+ for (const [col, value] of [['Identifier', identifier], ['Retention', retention]]) {
126
+ if ((value ?? '') === '')
127
+ violations.push({ artifact: ENTITIES_FILE, reason: 'empty_cell', line: row.line, id: `${name} · ${col}` });
128
+ }
129
+ if (requireComplete) {
130
+ for (const [col, value] of [['Identifier', identifier], ['Status lifecycle', lifecycle], ['Classification', classification], ['Retention', retention]]) {
131
+ if ((value ?? '') === PENDING)
132
+ violations.push({ artifact: ENTITIES_FILE, reason: 'incomplete_cell', line: row.line, id: `${name} · ${col}` });
133
+ }
134
+ }
135
+ entities.set(name, { name, line: row.line, identifier: identifier ?? '', lifecycle: lifecycle ?? '', classification: classification ?? '', retention: retention ?? '' });
136
+ }
137
+ }
138
+ // 2 ─ Attributes: one `### <Entity>` table each
139
+ let attributes = 0;
140
+ const withAttributes = new Set();
141
+ const attributesSection = sections.find((s) => s.heading === SECTIONS.attributes);
142
+ if (!attributesSection) {
143
+ violations.push({ artifact: ENTITIES_FILE, reason: 'missing_section', id: SECTIONS.attributes });
144
+ }
145
+ else {
146
+ const sub = table.splitSections(attributesSection.body.map((l) => l.text), 3);
147
+ for (const s of sub) {
148
+ const name = s.heading.trim();
149
+ const line = attributesSection.body[s.line - 1]?.n ?? attributesSection.line;
150
+ if (!entities.has(name)) {
151
+ violations.push({ artifact: ENTITIES_FILE, reason: 'unknown_entity', line, id: name.slice(0, 60) });
152
+ continue;
153
+ }
154
+ const tables = table.parseTables(s.body.map((l, i) => ({ n: attributesSection.body[s.line + i]?.n ?? line, text: l.text })));
155
+ if (tables.length === 0 || !sameHeader(tables[0].header, ATTRIBUTES_HEADER)) {
156
+ violations.push({ artifact: ENTITIES_FILE, reason: 'bad_table_shape', line, id: `${SECTIONS.attributes} · ${name}` });
157
+ continue;
158
+ }
159
+ if (tables[0].rows.length > 0)
160
+ withAttributes.add(name);
161
+ for (const row of tables[0].rows) {
162
+ attributes += 1;
163
+ if ((row.cells[0] ?? '').trim() === '' || (row.cells[1] ?? '').trim() === '') {
164
+ violations.push({ artifact: ENTITIES_FILE, reason: 'empty_cell', line: row.line, id: `${name} · Attribute` });
165
+ }
166
+ if (!REQUIRED_VALUES.includes((row.cells[2] ?? '').trim()))
167
+ violations.push({ artifact: ENTITIES_FILE, reason: 'bad_required', line: row.line, id: name });
168
+ if (requireComplete) {
169
+ for (const [col, idx] of [['Type', 1], ['Required', 2]]) {
170
+ if ((row.cells[idx] ?? '').trim() === PENDING)
171
+ violations.push({ artifact: ENTITIES_FILE, reason: 'incomplete_cell', line: row.line, id: `${name} · ${(row.cells[0] ?? '').trim()} · ${col}` });
172
+ }
173
+ }
174
+ }
175
+ }
176
+ }
177
+ if (requireComplete) {
178
+ for (const e of entities.values()) {
179
+ if (!withAttributes.has(e.name))
180
+ violations.push({ artifact: ENTITIES_FILE, reason: 'attributes_missing', line: e.line, id: e.name });
181
+ }
182
+ }
183
+ // 3 ─ Relationships
184
+ let relationships = 0;
185
+ const relTable = sectionTable(sections, SECTIONS.relationships, RELATIONSHIPS_HEADER, violations);
186
+ if (relTable) {
187
+ for (const row of relTable.rows) {
188
+ relationships += 1;
189
+ const [from, cardinality, to] = row.cells.map((c) => c.trim());
190
+ for (const end of [from, to]) {
191
+ if (!entities.has(end ?? ''))
192
+ violations.push({ artifact: ENTITIES_FILE, reason: 'unknown_entity', line: row.line, id: (end ?? '').slice(0, 60) });
193
+ }
194
+ if (!CARDINALITY_RE.test(cardinality ?? ''))
195
+ violations.push({ artifact: ENTITIES_FILE, reason: 'bad_cardinality', line: row.line });
196
+ }
197
+ }
198
+ // 4 ─ Lifecycles
199
+ let lifecycles = 0;
200
+ const withStates = new Set();
201
+ const lifeTable = sectionTable(sections, SECTIONS.lifecycles, LIFECYCLES_HEADER, violations);
202
+ if (lifeTable) {
203
+ for (const row of lifeTable.rows) {
204
+ lifecycles += 1;
205
+ const name = (row.cells[0] ?? '').trim();
206
+ if (!entities.has(name)) {
207
+ violations.push({ artifact: ENTITIES_FILE, reason: 'unknown_entity', line: row.line, id: name.slice(0, 60) });
208
+ continue;
209
+ }
210
+ withStates.add(name);
211
+ if ((row.cells[1] ?? '').trim() === '')
212
+ violations.push({ artifact: ENTITIES_FILE, reason: 'empty_cell', line: row.line, id: `${name} · State` });
213
+ }
214
+ }
215
+ for (const e of entities.values()) {
216
+ if (e.lifecycle === 'yes' && !withStates.has(e.name))
217
+ violations.push({ artifact: ENTITIES_FILE, reason: 'lifecycle_without_states', line: e.line, id: e.name });
218
+ if (e.lifecycle === 'none' && withStates.has(e.name))
219
+ violations.push({ artifact: ENTITIES_FILE, reason: 'states_without_lifecycle', line: e.line, id: e.name });
220
+ }
221
+ // 5 ─ Every data object a step touches is an entity
222
+ let stepObjectsChecked = 0;
223
+ for (const { line, cell } of stepObjects) {
224
+ if (cell.trim() === PENDING || cell.trim() === '')
225
+ continue;
226
+ for (const token of cell.split(',').map((t) => t.trim()).filter(Boolean)) {
227
+ const m = DATA_OBJECT_RE.exec(token);
228
+ if (!m)
229
+ continue; // a malformed token is process-check's fault to report
230
+ stepObjectsChecked += 1;
231
+ if (!entities.has(m[1]))
232
+ violations.push({ artifact: PROCESSES_FILE, reason: 'unknown_entity', line, id: m[1] });
233
+ }
234
+ }
235
+ return { ok: violations.length === 0, entities: entities.size, attributes, relationships, lifecycles, step_objects_checked: stepObjectsChecked, violations };
236
+ }
237
+ /** The `Data objects` cells of every step table in `_processes.md` § Steps. */
238
+ function stepDataObjects(content) {
239
+ const lines = content.split(/\r?\n/);
240
+ const steps = table.splitSections(lines, 2).find((s) => s.heading === 'Steps');
241
+ if (!steps)
242
+ return [];
243
+ const out = [];
244
+ for (const t of table.parseTables(steps.body)) {
245
+ const col = t.header.findIndex((h) => h.trim().toLowerCase() === STEPS_DATA_OBJECTS_COLUMN.toLowerCase());
246
+ if (col === -1)
247
+ continue;
248
+ for (const row of t.rows)
249
+ out.push({ line: row.line, cell: row.cells[col] ?? '' });
250
+ }
251
+ return out;
252
+ }
253
+ function checkProjectEntities(cwd, requireComplete = false) {
254
+ const read = readIfPresent(node_path_1.default.join(cwd, ENTITIES_FILE));
255
+ if (read.kind === 'absent')
256
+ return null;
257
+ const empty = { entities: 0, attributes: 0, relationships: 0, lifecycles: 0, step_objects_checked: 0 };
258
+ if (read.kind === 'unreadable')
259
+ return { ok: false, file: ENTITIES_FILE, ...empty, violations: [{ artifact: ENTITIES_FILE, reason: 'artifact_unreadable' }] };
260
+ const processes = readIfPresent(node_path_1.default.join(cwd, PROCESSES_FILE));
261
+ const stepObjects = processes.kind === 'ok' ? stepDataObjects(processes.content) : [];
262
+ const result = checkEntities(read.content, stepObjects, requireComplete);
263
+ if (processes.kind === 'unreadable')
264
+ result.violations.push({ artifact: PROCESSES_FILE, reason: 'artifact_unreadable' });
265
+ result.ok = result.violations.length === 0;
266
+ return { file: ENTITIES_FILE, ...result };
267
+ }
268
+ function formatViolation(v) {
269
+ const where = v.line !== undefined ? `:${v.line}` : '';
270
+ const what = v.id !== undefined ? ` (${v.id})` : '';
271
+ return ` - ${v.artifact}${where}: ${v.reason}${what}`;
272
+ }
273
+ // ─── CLI adapter ──────────────────────────────────────────────────────────────
274
+ /**
275
+ * CLI adapter for `pp-tools discovery entity-check [--require-complete]`
276
+ * (dotted form `query discovery.entity-check`). An absent `_entities.md`
277
+ * exits 0 with `{ ok: true, present: false }`. Structural faults exit via
278
+ * `DISCOVERY_ENTITY_CHECK_FAILED`; when every fault is an `incomplete_cell` or
279
+ * `attributes_missing` raised by `--require-complete`, the identity is
280
+ * `DISCOVERY_ENTITY_CHECK_INCOMPLETE`, so a gate can tell "not finished" from
281
+ * "malformed".
282
+ */
283
+ function cmdDiscoveryEntityCheck(cwd, args, raw) {
284
+ const rest = args.slice(2);
285
+ const requireComplete = rest.includes('--require-complete');
286
+ if (rest.some((a) => a !== '--require-complete')) {
287
+ error(USAGE, ERROR_REASON.USAGE);
288
+ return; // unreachable — error() exits
289
+ }
290
+ const result = checkProjectEntities(cwd, requireComplete);
291
+ if (result === null) {
292
+ output({ ok: true, present: false, file: ENTITIES_FILE }, raw);
293
+ return;
294
+ }
295
+ const payload = {
296
+ ok: result.ok, present: true, file: result.file, entities: result.entities, attributes: result.attributes,
297
+ relationships: result.relationships, lifecycles: result.lifecycles, step_objects_checked: result.step_objects_checked,
298
+ };
299
+ if (!result.ok) {
300
+ output({ ...payload, violations: result.violations.length }, raw);
301
+ const onlyIncomplete = result.violations.every((v) => v.reason === 'incomplete_cell' || v.reason === 'attributes_missing');
302
+ error(`discovery entity-check: ${result.violations.length} violation(s):\n` + result.violations.map(formatViolation).join('\n'), onlyIncomplete ? ERROR_REASON.DISCOVERY_ENTITY_CHECK_INCOMPLETE : ERROR_REASON.DISCOVERY_ENTITY_CHECK_FAILED);
303
+ return; // unreachable
304
+ }
305
+ output(payload, raw);
306
+ }
307
+ module.exports = {
308
+ ENTITIES_FILE,
309
+ ENTITIES_HEADER,
310
+ ATTRIBUTES_HEADER,
311
+ RELATIONSHIPS_HEADER,
312
+ LIFECYCLES_HEADER,
313
+ CLASSIFICATION_LEVELS,
314
+ LIFECYCLE_VALUES,
315
+ CARDINALITY_RE,
316
+ DATA_OBJECT_RE,
317
+ ENTITY_CHECK_FAILED: ERROR_REASON.DISCOVERY_ENTITY_CHECK_FAILED,
318
+ ENTITY_CHECK_INCOMPLETE: ERROR_REASON.DISCOVERY_ENTITY_CHECK_INCOMPLETE,
319
+ checkEntities,
320
+ stepDataObjects,
321
+ checkProjectEntities,
322
+ cmdDiscoveryEntityCheck,
323
+ };