@dzhechkov/p-replicator 1.5.18 → 1.9.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 (89) hide show
  1. package/.dz-manifest.json +173 -57
  2. package/CHANGELOG.md +262 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +2 -2
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/05_architecture.md +6 -2
  8. package/README/eng/README.md +1 -1
  9. package/README/ru/01_quickstart.md +2 -2
  10. package/README/ru/02_user_guide.md +1 -1
  11. package/README/ru/03_admin_guide.md +2 -2
  12. package/README/ru/05_architecture.md +1 -1
  13. package/README/ru/README.md +1 -1
  14. package/README/ru/html/index.html +7 -7
  15. package/README.md +139 -16
  16. package/bin/cli.js +0 -0
  17. package/package.json +11 -10
  18. package/sbom.json +346 -56
  19. package/src/utils.js +4 -0
  20. package/templates/.claude/agents/doc-validator.md +2 -1
  21. package/templates/.claude/agents/product-discoverer.md +1 -1
  22. package/templates/.claude/commands/next.md +16 -0
  23. package/templates/.claude/commands/replicate.md +183 -9
  24. package/templates/.claude/commands/start.md +19 -1
  25. package/templates/.claude/hooks/autocommit-insights.cjs +95 -10
  26. package/templates/.claude/hooks/autocommit-plans.cjs +95 -10
  27. package/templates/.claude/hooks/autocommit-roadmap.cjs +96 -13
  28. package/templates/.claude/hooks/check-docs-complete.cjs +174 -0
  29. package/templates/.claude/hooks/check-growth-trace.cjs +191 -0
  30. package/templates/.claude/hooks/check-ports.cjs +232 -0
  31. package/templates/.claude/hooks/session-insights.cjs +13 -1
  32. package/templates/.claude/hooks/state-update.cjs +13 -1
  33. package/templates/.claude/hooks/statusline.cjs +145 -18
  34. package/templates/.claude/rules/docker-ports.md +123 -0
  35. package/templates/.claude/rules/replicate-pipeline.md +19 -6
  36. package/templates/.claude/rules/skill-interface-protocol.md +9 -0
  37. package/templates/.claude/settings.json +5 -5
  38. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/01-detect-parse.md +57 -14
  39. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/02-analyze-map.md +9 -7
  40. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +6 -4
  41. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/08-skill-composition.md +2 -2
  42. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/enhanced-recommendations.md +6 -4
  43. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/extended-mapping.md +2 -2
  44. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-agents.md +2 -2
  45. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/ddd-skills.md +1 -1
  46. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/enhanced-claude-md.md +2 -2
  47. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +2 -2
  48. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-suggestions.md +11 -2
  49. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +1 -1
  50. package/templates/.claude/skills/requirements-validator/SKILL.md +59 -0
  51. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +28 -0
  52. package/templates/.claude/skills/reverse-engineering-unicorn/modules/01-intelligence.md +4 -4
  53. package/templates/.claude/skills/reverse-engineering-unicorn/modules/02-product-customers.md +2 -2
  54. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +9 -1
  55. package/templates/.claude/skills/reverse-engineering-unicorn/modules/03-market-competition.md +3 -3
  56. package/templates/.claude/skills/reverse-engineering-unicorn/modules/04-business-finance.md +3 -3
  57. package/templates/.claude/skills/reverse-engineering-unicorn/modules/05-growth-engine.md +132 -12
  58. package/templates/.claude/skills/reverse-engineering-unicorn/modules/06-playbook-synthesis.md +1 -1
  59. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +152 -10
  60. package/tests/e2e/lifecycle.test.js +21 -10
  61. package/tests/snapshot/baseline.json +42 -38
  62. package/tests/snapshot/update-baseline.js +2 -1
  63. package/tests/unit/adr-decision-coverage.test.js +137 -0
  64. package/tests/unit/adr-scanner-contract.test.js +108 -0
  65. package/tests/unit/autocommit-deletion.test.js +242 -0
  66. package/tests/unit/check-docs-complete.test.js +249 -0
  67. package/tests/unit/check-growth-trace.test.js +188 -0
  68. package/tests/unit/check-ports.test.js +184 -0
  69. package/tests/unit/db-port-rule.test.js +216 -0
  70. package/tests/unit/detect-parse-anchor.test.js +109 -0
  71. package/tests/unit/external-dependency-check.test.js +209 -0
  72. package/tests/unit/growth-axes-and-compliance.test.js +169 -0
  73. package/tests/unit/growth-gate-conditional.test.js +122 -0
  74. package/tests/unit/growth-module-b2b-gate.test.js +122 -0
  75. package/tests/unit/growth-requirements-bridge.test.js +127 -0
  76. package/tests/unit/hooks-project-anchored.test.js +223 -0
  77. package/tests/unit/hooks-report-failures.test.js +207 -0
  78. package/tests/unit/module-copy-identity.test.js +76 -0
  79. package/tests/unit/pipeline-file-ownership.test.js +95 -0
  80. package/tests/unit/roadmap-one-schema.test.js +179 -0
  81. package/tests/unit/skill-paths-prebaked.test.js +174 -0
  82. package/tests/unit/sparc-reconciliation.test.js +117 -0
  83. package/tests/unit/spec-pseudocode-traceability.test.js +146 -0
  84. package/tests/unit/statusline-honest-labels.test.js +178 -0
  85. package/tests/unit/statusline-two-roots.test.js +237 -0
  86. package/tests/unit/sync-templates-guard.test.js +209 -0
  87. package/tests/unit/utils.test.js +2 -2
  88. package/tests/unit/validation-gate-teeth.test.js +158 -0
  89. package/LICENSE +0 -21
@@ -10,35 +10,118 @@ const fs = require('node:fs');
10
10
  const path = require('node:path');
11
11
  const { execFileSync } = require('node:child_process');
12
12
 
13
- const TARGET = path.resolve(process.cwd(), '.claude', 'feature-roadmap.json');
14
- const RELATIVE = path.relative(process.cwd(), TARGET);
15
- const SILENT = { stdio: 'ignore' };
13
+ // The project root, never the process cwd: a `cd` inside any tool call moves cwd for the rest of
14
+ // the session, and these hooks are non-blocking, so a wrong anchor fails SILENTLY. CLAUDE_PROJECT_DIR
15
+ // first the host is authoritative about what the project is. `__dirname` second: a hook always
16
+ // lives at <project>/.claude/hooks/<x>.cjs, so its own location settles the root with no cooperation
17
+ // from anyone, which is what keeps this working when the variable is absent (hand-run, older host).
18
+ const ENV_ROOT = process.env.CLAUDE_PROJECT_DIR;
19
+ // isAbsolute, not just truthy: a RELATIVE value would still be resolved against the drifting
20
+ // cwd, which is the very bug this anchor exists to remove.
21
+ const ROOT = (ENV_ROOT && path.isAbsolute(ENV_ROOT))
22
+ ? ENV_ROOT
23
+ : path.resolve(__dirname, '..', '..');
24
+
25
+ const TARGET = path.resolve(ROOT, '.claude', 'feature-roadmap.json');
26
+ const RELATIVE = path.relative(ROOT, TARGET);
27
+ // cwd: ROOT — the paths below are relative to ROOT, so git must run there too.
28
+ // stderr is PIPED, not ignored: on success nothing is printed anyway, and on failure git's
29
+ // own words are the only thing that tells a reader WHY. Discarding them leaves a report that
30
+ // names what failed and not why, which is half a report.
31
+ const SILENT = { stdio: ['ignore', 'pipe', 'pipe'], cwd: ROOT };
32
+
33
+ // What the hook was DOING when it failed. The outer catch also sees staging failures and a
34
+ // missing git binary, and reporting either of those as "could not commit" would send the reader
35
+ // looking in the wrong place.
36
+ let stage = 'start';
16
37
 
17
38
  function git(args) {
18
39
  return execFileSync('git', args, SILENT);
19
40
  }
20
41
 
21
42
  try {
22
- // Skip if file doesn't exist or we're not in a git repo.
23
- if (!fs.existsSync(TARGET)) process.exit(0);
24
- try { git(['rev-parse', '--git-dir']); } catch { process.exit(0); }
43
+ // The repository probe comes FIRST now, because the absence check below needs to ask git a
44
+ // question and there is no point asking outside a repository.
45
+ stage = 'inspect the repository';
46
+ try {
47
+ git(['rev-parse', '--git-dir']);
48
+ } catch (probeErr) {
49
+ // "Not a git repository" is the ordinary case and stays silent. Everything else — no git on
50
+ // PATH, dubious ownership, a permission error — is a real failure that used to look exactly
51
+ // like it, which is how a broken machine and an ordinary directory became indistinguishable.
52
+ const why = String((probeErr && probeErr.stderr) || '').trim();
53
+ if (!why || /not a git repository/i.test(why)) process.exit(0);
54
+ throw probeErr;
55
+ }
56
+
57
+ // Absent — but absent HOW? A path that never existed is nothing to do, as before. A path git
58
+ // still tracks is a DELETION, and a deletion is exactly the change this hook exists to record.
59
+ //
60
+ // `git ls-files` is the discriminator, and it must run BEFORE staging: staging a deletion
61
+ // removes the entry from the index, after which the same question answers "not tracked".
62
+ // CORRECTION, measured: `--error-unmatch` also discriminates correctly here, for files AND for
63
+ // directories — 0 for a deleted-but-tracked path, 1 for one that never existed. An earlier note
64
+ // here claimed otherwise; that claim came from measuring it AFTER staging, which is what made it
65
+ // fail, not the directory-ness. `ls-files --` is preferred only because it answers with DATA
66
+ // (empty or not) instead of by throwing, so the ordinary case needs no exception handling.
67
+ //
68
+ // Simply DELETING the existence guard would have been the naive fix and would have broken the
69
+ // reporting that shipped yesterday: `git add` on a path that never existed exits 128, so every
70
+ // session stop in every project without this artifact would print a failure line. MEASURED.
71
+ if (!fs.existsSync(TARGET)) {
72
+ // A bare `catch { tracked = '' }` here would turn a REAL git failure into "never existed"
73
+ // and skip a deletion that should have been recorded — a silent bypass of the very thing
74
+ // this feature adds. A failure is rethrown into the reporting path; only a genuinely
75
+ // empty answer means the artifact never lived here.
76
+ const tracked = String(git(['ls-files', '--', RELATIVE]) || '').trim();
77
+ if (!tracked) process.exit(0); // never existed here — silent, exactly as before
78
+ }
25
79
 
26
- // Stage only the target file.
80
+ // Stage the target (or its removal).
81
+ stage = 'stage the change';
27
82
  git(['add', '--', RELATIVE]);
28
83
 
29
84
  // Check whether anything is staged for THIS path.
30
85
  // `git diff --cached --quiet -- <path>` exits 0 = no diff, 1 = diff exists.
31
- let hasDiff = false;
86
+ // Ask git WHAT it staged, not merely WHETHER something changed — same subprocess, strictly
87
+ // more information. Deriving "this is a removal" from whether the path still exists was
88
+ // wrong for a directory: deleting ONE file inside it, or deleting every tracked file while
89
+ // an ignored one keeps the directory present, would have been committed as an update and
90
+ // defeated the very search this feature promises.
91
+ let staged = '';
32
92
  try {
33
- git(['diff', '--cached', '--quiet', '--', RELATIVE]);
93
+ staged = String(git(['diff', '--cached', '--name-status', '--', RELATIVE]) || '').trim();
34
94
  } catch {
35
- hasDiff = true;
95
+ // No HEAD yet (an unborn repository): there is no history to record a removal against.
96
+ staged = '';
36
97
  }
98
+ const deleted = staged.split('\n').some((l) => /^D/.test(l.trim()));
99
+ const hasDiff = staged.length > 0;
37
100
 
38
101
  if (hasDiff) {
39
- git(['commit', '--only', '--', RELATIVE, '-m', 'docs(roadmap): auto-update']);
102
+ // -m BEFORE the `--`: everything after `--` is a PATHSPEC, so the old order made git
103
+ // look for files literally named '-m' and 'docs(roadmap): auto-update' — it failed every
104
+ // time, from every directory, and this hook exits 0 on failure, so nobody saw it.
105
+ stage = 'commit';
106
+ // A deletion gets its own subject, so the event is findable in `git log` without
107
+ // reading diffs — which is the whole point of recording it.
108
+ git(['commit', '-m', deleted ? 'docs(roadmap): auto-remove' : 'docs(roadmap): auto-update', '--only', '--', RELATIVE]);
40
109
  }
41
- } catch (_err) {
42
- // Best-effort — never break Claude session on commit failures.
110
+ } catch (err) {
111
+ // Best-effort — never break the Claude session on commit failures. But "not breaking the session"
112
+ // and "saying nothing" are different things, and only the first one is the contract: it is about
113
+ // the EXIT CODE. Silence is what let a permanent defect live here undetected — `-m` after `--`
114
+ // made every commit fail, from every directory, forever, and nothing said so.
115
+ //
116
+ // A held index.lock, a missing user.email, a repository pre-commit hook that rejected the commit,
117
+ // an ignored target: each now costs one line and still exits 0. The ordinary "nothing to commit"
118
+ // path never reaches here, deliberately — a notice that fires when nothing is wrong trains people
119
+ // to ignore notices, and the next real failure scrolls past with them.
120
+ const artifactName = '.claude/feature-roadmap.json';
121
+ const gitSaid = err && err.stderr ? String(err.stderr).trim() : '';
122
+ const detail = (gitSaid || String((err && err.message) || err)).split('\n')
123
+ .map((l) => l.trim()).filter(Boolean)[0] || 'unknown error';
124
+ process.stdout.write('[autocommit-roadmap] could not ' + stage + ' — ' + artifactName + ': '
125
+ + detail.slice(0, 200) + '\n');
43
126
  process.exit(0);
44
127
  }
@@ -0,0 +1,174 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-docs-complete.cjs — the cheap question, asked before the expensive one.
6
+ *
7
+ * Phase 2 of /replicate launches a SWARM of validation agents over whatever Phase 1 produced. Between
8
+ * the two there was nothing: existence, emptiness and unfilled placeholders are decidable by this
9
+ * script, and sending a multi-agent swarm to discover them is a layer-1 check living at layer 3.
10
+ *
11
+ * NOT an event hook, like `state-update.cjs`, `check-ports.cjs` and `check-growth-trace.cjs`. This
12
+ * package's hooks are NON-BLOCKING by contract, so a hook could print but never refuse. Invoke it:
13
+ *
14
+ * node .claude/hooks/check-docs-complete.cjs [path-to-project]
15
+ *
16
+ * Exit codes:
17
+ * 0 every required document exists, has content, and carries no unfilled placeholder
18
+ * 1 a NAMED document is missing, empty, or still a template
19
+ * 2 THE CHECK DID NOT RUN — no docs/ directory, or it could not be read
20
+ *
21
+ * Honest limit, and it is printed on the passing path: this proves the documents were WRITTEN, not
22
+ * that they are correct. Correctness is what the Phase-2 swarm is for; this only stops the swarm
23
+ * being spent discovering an empty file.
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ /** What Phase 1 writes. `optional: true` mirrors replicate.md's own `(if applicable)`. */
30
+ const DOCS = [
31
+ { file: 'PRD.md' },
32
+ { file: 'Solution_Strategy.md' },
33
+ { file: 'Specification.md' },
34
+ { file: 'Pseudocode.md' },
35
+ { file: 'Architecture.md' },
36
+ { file: 'Refinement.md' },
37
+ { file: 'Completion.md' },
38
+ { file: 'Research_Findings.md' },
39
+ { file: 'Final_Summary.md' },
40
+ { file: 'C4_Diagrams.md', optional: true },
41
+ { file: 'ADR.md', optional: true },
42
+ ];
43
+
44
+ /** Below this a document has a heading and nothing under it. */
45
+ const MIN_CHARS = 200;
46
+
47
+ function say(s) { process.stdout.write(s + '\n'); }
48
+
49
+ function cannotCheck(reason, hint) {
50
+ say('⚠️ проверка НЕ выполнена: ' + reason);
51
+ if (hint) say(' ' + hint);
52
+ process.exit(2);
53
+ }
54
+
55
+ /**
56
+ * Unfilled placeholders — and the reason this is split into two confidence levels.
57
+ *
58
+ * The first version used one rule: a bracketed token not followed by `(`. Cross-family review
59
+ * destroyed it with two inputs, both REPRODUCED before this rewrite:
60
+ *
61
+ * 1. `A[Web App]` — a mermaid node. The BUNDLED sparc-prd-mini skill REQUIRES mermaid diagrams in
62
+ * Architecture.md (SKILL.md:570-583), so the gate blocked every normally generated project.
63
+ * 2. `[GAP: needs performance targets]` — which Phase 1 writes DELIBERATELY in --from-docs mode for
64
+ * Phase 2 to resolve (replicate.md:82-94). Blocking on it deadlocks the documented flow: the
65
+ * only step that can clear the marker is the one the gate refuses to start.
66
+ *
67
+ * The lesson is about CONFIDENCE, not about patterns. A false block here stops the whole pipeline,
68
+ * which is worse than a missed placeholder the Phase-2 swarm would have caught anyway. So:
69
+ *
70
+ * BLOCKING — shapes that cannot be anything else: `{{…}}`, TODO/TBD/XXX/FIXME.
71
+ * WARNING — bracketed prose. Reported by name, never blocking, because this script cannot tell
72
+ * `[описание продукта]` from a diagram label or a citation without understanding the
73
+ * document, and guessing wrong costs more than staying quiet.
74
+ */
75
+
76
+ /** Fenced code blocks hold mermaid, arrays and code — none of it prose, none of it ours to judge. */
77
+ function stripFences(body) {
78
+ return body.replace(/^```[\s\S]*?^```/gm, '').replace(/`[^`\n]*`/g, '');
79
+ }
80
+
81
+ const BLOCKING = [
82
+ { re: /\{\{[^}\n]{1,80}\}\}/g, what: 'незаполненный {{шаблон}}' },
83
+ { re: /(?<![\p{L}\p{N}])(TODO|TBD|XXX|FIXME)(?![\p{L}\p{N}])/giu, what: 'маркер TODO/TBD/XXX' },
84
+ ];
85
+
86
+ /** A structured marker Phase 2 OWNS. It must reach Phase 2, so it is never a finding here. */
87
+ const GAP = /\[GAP:[^\]\n]*\]/g;
88
+
89
+ const SUSPECT = /\[[^\]\n]{1,80}\](?![(\[])/g;
90
+
91
+ function scan(body) {
92
+ const clean = stripFences(body).replace(GAP, '');
93
+ const blocking = [];
94
+ for (const { re, what } of BLOCKING) {
95
+ re.lastIndex = 0;
96
+ for (let m = re.exec(clean); m !== null && blocking.length < 3; m = re.exec(clean)) {
97
+ blocking.push(what + ' «' + m[0].slice(0, 40) + '»');
98
+ }
99
+ }
100
+ const warn = [];
101
+ SUSPECT.lastIndex = 0;
102
+ for (let m = SUSPECT.exec(clean); m !== null && warn.length < 3; m = SUSPECT.exec(clean)) {
103
+ const t = m[0];
104
+ if (/^\[\^?\d+\]$/.test(t)) continue; // a citation or footnote, not a placeholder
105
+ warn.push(t.slice(0, 40));
106
+ }
107
+ return { blocking, warn };
108
+ }
109
+
110
+ function main() {
111
+ const root = process.argv[2] || '.';
112
+ const docs = path.join(root, 'docs');
113
+ let st;
114
+ try { st = fs.statSync(docs); } catch {
115
+ cannotCheck('нет каталога ' + docs,
116
+ 'Фаза 1 ещё не отработала — это НЕ «документы неполны», это «проверять нечего»');
117
+ }
118
+ if (!st.isDirectory()) cannotCheck(docs + ' существует, но это не каталог');
119
+
120
+ const problems = [];
121
+ const warnings = [];
122
+ let checked = 0;
123
+
124
+ for (const d of DOCS) {
125
+ const abs = path.join(docs, d.file);
126
+ let body;
127
+ try { body = fs.readFileSync(abs, 'utf-8'); } catch (e) {
128
+ if (e && e.code === 'ENOENT') {
129
+ if (!d.optional) problems.push(d.file + ': отсутствует');
130
+ continue; // an optional absence is a legitimate answer
131
+ }
132
+ cannotCheck('не читается ' + d.file + ': ' + ((e && e.message) || e));
133
+ }
134
+ checked++;
135
+ if (body.trim().length < MIN_CHARS) {
136
+ problems.push(d.file + ': пуст или почти пуст (' + body.trim().length + ' симв., порог '
137
+ + MIN_CHARS + ')');
138
+ continue;
139
+ }
140
+ const { blocking, warn } = scan(body);
141
+ if (blocking.length) problems.push(d.file + ': остались ' + blocking.join(', '));
142
+ if (warn.length) warnings.push(d.file + ': возможно незаполнено — ' + warn.join(', '));
143
+ }
144
+
145
+ if (!checked) {
146
+ cannotCheck('в ' + docs + ' не нашлось ни одного SPARC-документа',
147
+ 'каталог есть, но пуст — это не «всё в порядке»');
148
+ }
149
+
150
+ const sayWarnings = () => {
151
+ if (!warnings.length) return;
152
+ say('⚠️ на глаз (НЕ блокирует — скрипт не отличает шаблон от подписи к диаграмме):');
153
+ for (const w of warnings) say(' • ' + w);
154
+ };
155
+
156
+ if (problems.length) {
157
+ sayWarnings();
158
+ say('❌ документы Фазы 1 не готовы к валидации (' + problems.length + '):');
159
+ for (const p of problems) say(' • ' + p);
160
+ say(' Рой валидации запускать рано: он потратит агентов на то, что видно отсюда.');
161
+ process.exit(1);
162
+ }
163
+
164
+ sayWarnings();
165
+ say('✅ все обязательные документы на месте, непусты и без незаполненных шаблонов ('
166
+ + checked + ' проверено)');
167
+ say(' Ограничение: это доказывает, что документы НАПИСАНЫ, а не что они верны.');
168
+ say(' Верность — работа роя валидации Фазы 2; проверка лишь не даёт потратить его впустую.');
169
+ process.exit(0);
170
+ }
171
+
172
+ try { main(); } catch (err) {
173
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
174
+ }
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * check-growth-trace.cjs — did the M5 growth analysis survive into the Specification, or was it
6
+ * analysed and dropped?
7
+ *
8
+ * NOT an event hook. Like `state-update.cjs` and `check-ports.cjs`, it lives here because this
9
+ * directory already carries plain Node utilities; nothing registers it in settings.json. This is
10
+ * deliberate and load-bearing: this package's hooks are NON-BLOCKING by contract (pinned by
11
+ * tests/unit/hooks-project-anchored.test.js, which requires exit 0), so a hook could never refuse
12
+ * anything — it could only print. Invoke it:
13
+ *
14
+ * node .claude/hooks/check-growth-trace.cjs [path-to-project]
15
+ *
16
+ * Exit codes — three, and the third is the point:
17
+ * 0 every seed row is traced into docs/Specification.md, or rejected on the record
18
+ * 1 the seed table carries rows and the Specification traces none of them
19
+ * 2 THE CHECK DID NOT RUN — no brief, no Specification, or a seed table that would not parse
20
+ *
21
+ * A checker that answers "clean" when it could not look converts an unknown into a reassurance.
22
+ * An ABSENT brief means Phase 0 never ran (the --from-docs entry skips it); that is exit 2, never 0
23
+ * and never 1. "Phase 0 did not run" is not "nothing is missing".
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ const BRIEF = path.join('docs', 'product-discovery-brief.md');
30
+ const SPEC = path.join('docs', 'Specification.md');
31
+
32
+ /** The exact token, case-sensitive. Not a title, not a paraphrase — the same definition the
33
+ * validator's prose gate uses, so the two cannot disagree about what a mention is. */
34
+ const ID = /\bFR-GROWTH-(\d{3})\b/g;
35
+
36
+ /** A line that refuses an obligation. Shared by mentioned() and rejected() so the two rules cannot
37
+ * disagree about what a refusal looks like. */
38
+ const REJECT_WORD = /(отклон\w*|не берём|не беремся|не берем|rejected|declined|out of scope|вне области)/i;
39
+
40
+ function say(s) { process.stdout.write(s + '\n'); }
41
+
42
+ /** Exit 2 with a reason. Never merged with "clean": not-run and not-violated are different facts. */
43
+ function cannotCheck(reason, hint) {
44
+ say('⚠️ проверка НЕ выполнена: ' + reason);
45
+ if (hint) say(' ' + hint);
46
+ process.exit(2);
47
+ }
48
+
49
+ /**
50
+ * Read one required file. Asks about the EXACT path — never lists a directory and matches names
51
+ * against the listing, because a listing answers a different question than "does this file exist"
52
+ * and the two diverge on case, symlinks and unicode normalisation.
53
+ */
54
+ function readRequired(root, rel, absentReason, hint) {
55
+ const abs = path.join(root, rel);
56
+ let st;
57
+ try { st = fs.statSync(abs); } catch { cannotCheck(absentReason, hint); }
58
+ if (!st.isFile()) cannotCheck(rel + ' существует, но это не файл');
59
+ try { return fs.readFileSync(abs, 'utf-8'); } catch (e) {
60
+ cannotCheck('не читается ' + rel + ': ' + ((e && e.message) || e));
61
+ }
62
+ return '';
63
+ }
64
+
65
+ /**
66
+ * The seed rows, as the brief records them.
67
+ *
68
+ * A row is a markdown table row whose FIRST cell is an id. The template ships an example row with
69
+ * a placeholder id inside the module, so a row whose requirement cell is still a bracketed
70
+ * placeholder is a TEMPLATE row, not a real obligation, and counting it would let an untouched
71
+ * template look like a filled-in one.
72
+ */
73
+ function seedRows(brief) {
74
+ const rows = [];
75
+ for (const raw of brief.split('\n')) {
76
+ const line = raw.trim();
77
+ if (!line.startsWith('|')) continue;
78
+ const cells = line.split('|').map((c) => c.trim());
79
+ // cells[0] is '' for a leading pipe; the id lives in cells[1]
80
+ const m = /^FR-GROWTH-(\d{3})$/.exec(cells[1] || '');
81
+ if (!m) continue;
82
+ const requirement = cells[2] || '';
83
+ const isPlaceholder = /^\[.*\]$/.test(requirement) || requirement === '...' || requirement === '';
84
+ if (isPlaceholder) continue;
85
+ const status = (cells[5] || cells[4] || '').toUpperCase();
86
+ rows.push({ id: cells[1], speculative: status.includes('SPECULATIVE') });
87
+ }
88
+ return rows;
89
+ }
90
+
91
+ /**
92
+ * Ids the Specification mentions, by the same definition the validator's prose gate uses.
93
+ *
94
+ * A REJECTION LINE IS NOT A MENTION. The two rules overlap on exactly the case that matters: a line
95
+ * reading `FR-GROWTH-001 rejected` contains the exact token, so a naive mention rule reports the
96
+ * obligation as carried forward — and the reason requirement on the rejection path is never reached.
97
+ * MEASURED before this fix: that line exited 0. Cross-family review found the reason-check hole; the
98
+ * hole was one layer deeper, in which of the two rules got to answer first.
99
+ */
100
+ function mentioned(spec) {
101
+ const out = new Set();
102
+ for (const line of spec.split('\n')) {
103
+ if (REJECT_WORD.test(line)) continue; // a refusal is decided by rejected(), which wants a reason
104
+ ID.lastIndex = 0;
105
+ for (let m = ID.exec(line); m !== null; m = ID.exec(line)) out.add(m[0]);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * A row may also be REJECTED on the record instead of traced — the validator's prose gate says the
112
+ * same. A rejection is a line naming the id together with a rejection word AND a reason marker,
113
+ * because "FR-GROWTH-004 не берём" with nothing after it is indistinguishable from forgetting.
114
+ */
115
+ function rejected(brief, spec, id) {
116
+ const re = new RegExp('^.*\\b' + id + '\\b.*$', 'gm');
117
+ for (const hay of [brief, spec]) {
118
+ for (const line of hay.match(re) || []) {
119
+ const m = REJECT_WORD.exec(line);
120
+ if (!m) continue;
121
+ // The reason must live AFTER the rejection word. Scanning the whole line was a false-clean:
122
+ // cross-family review found that `FR-GROWTH-001 rejected` passed, because the reason pattern
123
+ // included a bare hyphen and the IDENTIFIER contains two of them. MEASURED before the fix —
124
+ // that exact line exited 0. So: look only at the tail, and never at punctuation alone.
125
+ const tail = line.slice(m.index + m[0].length);
126
+ // A reason is WORDS, not a dash. A separator may introduce it but can never be it.
127
+ const hasReason = /[\p{L}\p{N}][\p{L}\p{N}\s]{6,}/u.test(tail.replace(/^[\s:—–-]+/, ''));
128
+ if (hasReason) return true;
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ function main() {
135
+ const root = process.argv[2] || '.';
136
+ try { if (!fs.statSync(root).isDirectory()) cannotCheck('это не каталог: ' + root); }
137
+ catch { cannotCheck('путь не существует: ' + root); }
138
+
139
+ const brief = readRequired(root, BRIEF,
140
+ 'нет файла ' + BRIEF,
141
+ 'это значит, что Фаза 0 не запускалась (вход --from-docs её пропускает) — а НЕ что требований по росту не нужно');
142
+
143
+ const rows = seedRows(brief);
144
+
145
+ // A REUSED id makes the brief malformed, and malformed is exit 2 — never a pass. The module's own
146
+ // rule is that a number is never reused; when it is, two distinct obligations share one token and
147
+ // a SINGLE mention in the Specification marks BOTH traced. Cross-family review found this, and it
148
+ // is the recurring shape: coverage counted over usable ITEMS instead of per POSITION.
149
+ const dupes = [...new Set(rows.map((r) => r.id).filter((id, i, a) => a.indexOf(id) !== i))];
150
+ if (dupes.length) {
151
+ cannotCheck('в таблице-семени повторяются идентификаторы: ' + dupes.join(', '),
152
+ 'номер FR-GROWTH-nnn не переиспользуется — пока дубли не разведены, одно упоминание в '
153
+ + 'Specification.md зачло бы сразу два разных требования');
154
+ }
155
+
156
+ if (!rows.length) {
157
+ // An empty seed is a legitimate answer ("нет"), but it is not this checker's business: there is
158
+ // nothing to trace. Saying "clean" here would claim a check that did not happen.
159
+ cannotCheck('в брифе нет ни одной заполненной строки FR-GROWTH-nnn',
160
+ 'либо M5 не запускался, либо таблица-семя осталась шаблоном — проверять нечего');
161
+ }
162
+
163
+ const spec = readRequired(root, SPEC, 'нет файла ' + SPEC,
164
+ 'без спецификации не с чем сверять — это не «всё прослежено»');
165
+
166
+ const seen = mentioned(spec);
167
+ const missing = rows.filter((r) => !seen.has(r.id) && !rejected(brief, spec, r.id));
168
+
169
+ if (missing.length === rows.length) {
170
+ say('❌ ни одно требование по росту не доехало до ' + SPEC + ':');
171
+ for (const r of missing) say(' • ' + r.id + (r.speculative ? ' (SPECULATIVE)' : ''));
172
+ say(' Разбор роста сделан и выброшен — это ровно тот класс потерь, который ловит проверка.');
173
+ process.exit(1);
174
+ }
175
+ if (missing.length) {
176
+ say('❌ часть требований по росту потеряна (' + missing.length + ' из ' + rows.length + '):');
177
+ for (const r of missing) say(' • ' + r.id + (r.speculative ? ' (SPECULATIVE)' : ''));
178
+ say(' Каждое надо либо перенести в ' + SPEC + ', либо отклонить С ПРИЧИНОЙ — молча уронить нельзя.');
179
+ process.exit(1);
180
+ }
181
+ say('✅ все ' + rows.length + ' требований по росту прослежены в ' + SPEC + ' либо отклонены с причиной');
182
+ say(' Ограничение: это доказывает, что обязательство ДОНЕСЛИ, а не что его построили.');
183
+ process.exit(0);
184
+ }
185
+
186
+ try {
187
+ main();
188
+ } catch (err) {
189
+ // Even an unexpected failure must not read as "clean".
190
+ cannotCheck('внутренняя ошибка проверки: ' + String((err && err.message) || err));
191
+ }