@dzhechkov/p-replicator 1.10.4 → 1.13.1
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/.dz-manifest.json +312 -76
- package/CHANGELOG.md +232 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +3 -3
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/04_api_reference.md +11 -5
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +3 -3
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/04_api_reference.md +11 -5
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +9 -9
- package/README.md +278 -39
- package/package.json +5 -4
- package/sbom.json +665 -75
- package/scripts/check-pipeline-gaps.sh +413 -0
- package/src/commands/doctor.js +94 -4
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +15 -0
- package/src/utils.js +35 -11
- package/templates/.claude/agents/harvest-coordinator.md +10 -1
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +81 -9
- package/templates/.claude/commands/go.md +9 -0
- package/templates/.claude/commands/harvest.md +39 -3
- package/templates/.claude/commands/myinsights.md +21 -26
- package/templates/.claude/commands/replicate.md +171 -37
- package/templates/.claude/commands/start.md +29 -0
- package/templates/.claude/hooks/capture-source-path.cjs +795 -0
- package/templates/.claude/hooks/check-canon.cjs +493 -0
- package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
- package/templates/.claude/hooks/check-external-deps.cjs +288 -0
- package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
- package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
- package/templates/.claude/hooks/check-job-contract.cjs +501 -0
- package/templates/.claude/hooks/check-look-origin.cjs +240 -0
- package/templates/.claude/hooks/check-look-trace.cjs +385 -0
- package/templates/.claude/hooks/check-metric-source.cjs +296 -0
- package/templates/.claude/hooks/check-model-cost.cjs +470 -0
- package/templates/.claude/hooks/check-ports.cjs +434 -24
- package/templates/.claude/hooks/check-source-version.cjs +312 -0
- package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
- package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
- package/templates/.claude/hooks/session-insights.cjs +158 -25
- package/templates/.claude/hooks/statusline.cjs +2 -2
- package/templates/.claude/hooks/write-insight.cjs +253 -0
- package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
- package/templates/.claude/rules/docker-ports.md +41 -19
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +13 -3
- package/templates/.claude/rules/honest-configuration.md +54 -0
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- package/templates/.claude/rules/insights-capture.md +10 -5
- package/templates/.claude/rules/long-running-job.md +73 -0
- package/templates/.claude/rules/model-call-cost.md +85 -0
- package/templates/.claude/rules/replicate-pipeline.md +123 -52
- package/templates/.claude/rules/skill-interface-protocol.md +1 -0
- package/templates/.claude/rules/swarm-file-evidence.md +46 -0
- package/templates/.claude/settings.json +13 -1
- package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
- package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
- package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
- package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
- package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
- package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
- package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
- package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
- package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
- package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
- package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
- package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
- package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
- package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
- package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
- package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
- package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
- package/tests/e2e/lifecycle.test.js +55 -9
- package/tests/e2e/packed-insights-writer.test.js +308 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
- package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
- package/tests/snapshot/baseline.json +72 -46
- package/tests/snapshot/templates.test.js +47 -0
- package/tests/unit/absence-is-not-emptiness.test.js +15 -1
- package/tests/unit/capture-source-path.test.js +492 -0
- package/tests/unit/check-canon.test.js +403 -0
- package/tests/unit/check-embed-contract.test.js +422 -0
- package/tests/unit/check-external-deps.test.js +363 -0
- package/tests/unit/check-file-ownership.test.js +388 -0
- package/tests/unit/check-handoff-manifest.test.js +410 -0
- package/tests/unit/check-job-contract.test.js +514 -0
- package/tests/unit/check-look-origin.test.js +180 -0
- package/tests/unit/check-look-trace.test.js +420 -0
- package/tests/unit/check-metric-source.test.js +325 -0
- package/tests/unit/check-model-cost.test.js +425 -0
- package/tests/unit/check-pipeline-gaps.test.js +94 -0
- package/tests/unit/check-ports.test.js +773 -2
- package/tests/unit/check-source-version.test.js +344 -0
- package/tests/unit/check-swarm-receipts.test.js +231 -0
- package/tests/unit/check-webhook-contract.test.js +536 -0
- package/tests/unit/db-port-rule.test.js +43 -6
- package/tests/unit/detection-ladder-contract.test.js +302 -0
- package/tests/unit/detection-ladder-registry.test.js +52 -0
- package/tests/unit/doctor-insight-flow.test.js +315 -0
- package/tests/unit/external-dependency-check.test.js +19 -19
- package/tests/unit/generator-swarm-contract.test.js +287 -0
- package/tests/unit/guard-honest-input-meta.test.js +64 -0
- package/tests/unit/honest-failure-rules.test.js +574 -0
- package/tests/unit/hooks-project-anchored.test.js +67 -3
- package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
- package/tests/unit/insights-dz-delegation.test.js +197 -0
- package/tests/unit/insights-writer.test.js +285 -0
- package/tests/unit/look-phase-contract.test.js +231 -0
- package/tests/unit/negative-conclusion-gate.test.js +300 -0
- package/tests/unit/quote-provenance.test.js +122 -0
- package/tests/unit/shipped-suite-context.test.js +3 -1
- package/tests/unit/traceability-machine-ids.test.js +413 -0
- package/tests/unit/traceability-negative-fixture.test.js +322 -0
- package/tests/unit/utils.test.js +40 -2
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { execFileSync } = require('node:child_process');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const Module = require('node:module');
|
|
8
|
+
const os = require('node:os');
|
|
9
|
+
const path = require('node:path');
|
|
10
|
+
const { Worker } = require('node:worker_threads');
|
|
11
|
+
|
|
12
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
13
|
+
const RULE_DIR = path.join(PKG_DIR, 'templates', '.claude', 'rules');
|
|
14
|
+
const RULE_FILE = 'cost-of-detection-ladder.md';
|
|
15
|
+
const RULE_PATH = path.join(RULE_DIR, RULE_FILE);
|
|
16
|
+
const TEST_PATH = 'tests/unit/detection-ladder-contract.test.js';
|
|
17
|
+
const CLI = path.join(PKG_DIR, 'bin', 'cli.js');
|
|
18
|
+
|
|
19
|
+
const read = (file) => fs.readFileSync(file, 'utf8');
|
|
20
|
+
|
|
21
|
+
function npmPackDryRunFiles() {
|
|
22
|
+
const npmCli = process.env.npm_execpath && fs.existsSync(process.env.npm_execpath)
|
|
23
|
+
? process.env.npm_execpath
|
|
24
|
+
: path.join(
|
|
25
|
+
path.dirname(require.resolve('npm/package.json', { paths: Module.globalPaths })),
|
|
26
|
+
'bin',
|
|
27
|
+
'npm-cli.js');
|
|
28
|
+
const cache = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-npm-pack-cache-'));
|
|
29
|
+
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
let stdout = '';
|
|
32
|
+
let stderr = '';
|
|
33
|
+
const worker = new Worker(`
|
|
34
|
+
const { workerData } = require('node:worker_threads');
|
|
35
|
+
process.argv = ['node', 'npm', 'pack', workerData.pkgDir, '--dry-run', '--json'];
|
|
36
|
+
process.env.npm_config_cache = workerData.cache;
|
|
37
|
+
require(workerData.npmCli);
|
|
38
|
+
`, {
|
|
39
|
+
eval: true,
|
|
40
|
+
workerData: { npmCli, pkgDir: PKG_DIR, cache },
|
|
41
|
+
stdout: true,
|
|
42
|
+
stderr: true,
|
|
43
|
+
});
|
|
44
|
+
worker.stdout.setEncoding('utf8');
|
|
45
|
+
worker.stderr.setEncoding('utf8');
|
|
46
|
+
worker.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
47
|
+
worker.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
48
|
+
worker.once('error', (error) => {
|
|
49
|
+
fs.rmSync(cache, { recursive: true, force: true });
|
|
50
|
+
reject(error);
|
|
51
|
+
});
|
|
52
|
+
worker.once('exit', (code) => {
|
|
53
|
+
fs.rmSync(cache, { recursive: true, force: true });
|
|
54
|
+
if (code !== 0) {
|
|
55
|
+
reject(new Error(`npm pack --dry-run --json exited ${code}: ${stderr}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const result = JSON.parse(stdout);
|
|
60
|
+
resolve(result[0].files.map((entry) => entry.path));
|
|
61
|
+
} catch (error) {
|
|
62
|
+
reject(new Error(`npm pack --dry-run --json returned invalid JSON: ${error.message}`));
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function ladderProblems(text) {
|
|
69
|
+
const problems = [];
|
|
70
|
+
const layers = [
|
|
71
|
+
/### Layer 1 — Deterministic test, CI check, or static guard/i,
|
|
72
|
+
/### Layer 2 — Always-loaded governance/i,
|
|
73
|
+
/### Layer 3 — Pipeline gate/i,
|
|
74
|
+
/### Layer 4 — Skill or reviewer judgment/i,
|
|
75
|
+
/### Layer 5 — Agent memory or informal recall/i,
|
|
76
|
+
];
|
|
77
|
+
let previous = -1;
|
|
78
|
+
for (const layer of layers) {
|
|
79
|
+
const index = text.search(layer);
|
|
80
|
+
if (index < 0) problems.push(`missing ${layer}`);
|
|
81
|
+
if (index >= 0 && index <= previous) problems.push(`out of order ${layer}`);
|
|
82
|
+
previous = Math.max(previous, index);
|
|
83
|
+
}
|
|
84
|
+
if (!/strongest layer that can reliably express the property/i.test(text)) {
|
|
85
|
+
problems.push('missing strongest-capable-layer rule');
|
|
86
|
+
}
|
|
87
|
+
if (!/critic\/reviewer will catch it/i.test(text)) {
|
|
88
|
+
problems.push('missing critic/reviewer anti-pattern');
|
|
89
|
+
}
|
|
90
|
+
if (!/deterministic propert(?:y|ies)[\s\S]{0,180}(?:must not|never)[\s\S]{0,120}probabilistic review/i.test(text)) {
|
|
91
|
+
problems.push('anti-pattern does not protect deterministic properties');
|
|
92
|
+
}
|
|
93
|
+
return problems;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function bookReinforcementProblems(text) {
|
|
97
|
+
const problems = [];
|
|
98
|
+
const mappings = [
|
|
99
|
+
/Static structure or format\s*\|\s*Static check or deterministic test/i,
|
|
100
|
+
/Local behavior\s*\|\s*Unit test/i,
|
|
101
|
+
/Component interaction\s*\|\s*Integration or contract test/i,
|
|
102
|
+
/Behavior over time\s*\|\s*Monitor with a defined threshold or invariant/i,
|
|
103
|
+
/Runtime quantity\s*\|\s*Metric and threshold alert/i,
|
|
104
|
+
/Discrete transition\s*\|\s*Event or audit check/i,
|
|
105
|
+
/Failure resilience\s*\|\s*Controlled fault injection/i,
|
|
106
|
+
/Semantic or adversarial property\s*\|\s*Independent model-backed review gate/i,
|
|
107
|
+
];
|
|
108
|
+
if (!/check kind and enforcement layer are separate axes/i.test(text)) {
|
|
109
|
+
problems.push('missing two-axis decision');
|
|
110
|
+
}
|
|
111
|
+
for (const mapping of mappings) {
|
|
112
|
+
if (!mapping.test(text)) problems.push(`missing signal mapping ${mapping}`);
|
|
113
|
+
}
|
|
114
|
+
if (!/\| Cause \/ property \| Observable signal \| Check kind \| Layer \| Trigger \/ cadence \| Reaction \| Owner \|/i.test(text)) {
|
|
115
|
+
problems.push('missing closed-loop Reaction column');
|
|
116
|
+
}
|
|
117
|
+
if (!/Reaction must name a concrete action/i.test(text)) {
|
|
118
|
+
problems.push('Reaction is not mandatory and concrete');
|
|
119
|
+
}
|
|
120
|
+
if (!/block or return the change|repair the practice|revisit the decision/i.test(text)) {
|
|
121
|
+
problems.push('missing concrete reaction choices');
|
|
122
|
+
}
|
|
123
|
+
return problems;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function privateMarkerHits(text) {
|
|
127
|
+
const markers = [
|
|
128
|
+
/dz-harness-hub/i,
|
|
129
|
+
/\/home\/dz-projects-2026/i,
|
|
130
|
+
/packages\/@dzhechkov/i,
|
|
131
|
+
/\.claude\/rules\/feature-adr-conventions\.md/i,
|
|
132
|
+
/harness-core|harness-cli|health-advisor/i,
|
|
133
|
+
/\bdz guard\b/i,
|
|
134
|
+
/\bfeatures\//i,
|
|
135
|
+
/claim-check|discrimination-check|amendment gate|fa-improvements/i,
|
|
136
|
+
];
|
|
137
|
+
return markers.filter((marker) => marker.test(text)).map(String);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function backlinkProblems(ruleDir) {
|
|
141
|
+
const allusion = /cost.of.detection|strongest.*layer|layer [1-5]|слой [1-5]|лестниц/i;
|
|
142
|
+
const linked = /\[[^\]]+\]\((?:\.\/)?cost-of-detection-ladder\.md\)/i;
|
|
143
|
+
const linkLine = /\[[^\]]+\]\((?:\.\/)?cost-of-detection-ladder\.md\)/i;
|
|
144
|
+
const problems = [];
|
|
145
|
+
for (const name of fs.readdirSync(ruleDir).filter((entry) => entry.endsWith('.md'))) {
|
|
146
|
+
if (name === RULE_FILE) continue;
|
|
147
|
+
const text = read(path.join(ruleDir, name));
|
|
148
|
+
const allusionText = text
|
|
149
|
+
.split(/\r?\n/)
|
|
150
|
+
.filter((line) => !linkLine.test(line))
|
|
151
|
+
.join('\n');
|
|
152
|
+
if (allusion.test(allusionText) && !linked.test(text)) problems.push(name);
|
|
153
|
+
}
|
|
154
|
+
return problems;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function registryProblems(ruleDir, registryKeys) {
|
|
158
|
+
const files = fs.readdirSync(ruleDir)
|
|
159
|
+
.filter((entry) => entry.endsWith('.md'))
|
|
160
|
+
.map((entry) => path.basename(entry, '.md'))
|
|
161
|
+
.sort();
|
|
162
|
+
const keys = [...registryKeys].sort();
|
|
163
|
+
return files.length === keys.length && files.every((name, index) => name === keys[index])
|
|
164
|
+
? []
|
|
165
|
+
: [`template rules ${JSON.stringify(files)} != registry ${JSON.stringify(keys)}`];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function runnerProblems(pkg) {
|
|
169
|
+
return ['test', 'test:unit'].filter((script) => !pkg.scripts?.[script]?.includes(TEST_PATH));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function packagingProblems(pkg) {
|
|
173
|
+
return pkg.files?.includes('templates/') ? [] : ['package files[] omits templates/'];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scopeProblems(files) {
|
|
177
|
+
const packagePrefix = 'packages/@dzhechkov/p-replicator/';
|
|
178
|
+
const manifest = 'features/ship-detection-ladder/07_code_changes/change_manifest.md';
|
|
179
|
+
return files.filter((file) => !file.startsWith(packagePrefix) && file !== manifest);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
test('P1 — consumer ladder preserves the ordered contract and rejects an unlinked real mention', () => {
|
|
183
|
+
const canonical = read(RULE_PATH);
|
|
184
|
+
assert.deepEqual(ladderProblems(canonical), []);
|
|
185
|
+
assert.deepEqual(backlinkProblems(RULE_DIR), []);
|
|
186
|
+
|
|
187
|
+
const withoutLayerThree = canonical.replace(
|
|
188
|
+
/\n### Layer 3 — Pipeline gate[\s\S]*?(?=\n### Layer 4)/, '');
|
|
189
|
+
assert.ok(ladderProblems(withoutLayerThree).length > 0,
|
|
190
|
+
'removing a load-bearing layer must fire the ordered-contract safeguard');
|
|
191
|
+
|
|
192
|
+
const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-link-'));
|
|
193
|
+
try {
|
|
194
|
+
fs.writeFileSync(path.join(fixture, RULE_FILE), canonical);
|
|
195
|
+
fs.writeFileSync(path.join(fixture, 'unlinked-rule.md'), 'Put this safeguard on layer 3.\n');
|
|
196
|
+
assert.deepEqual(backlinkProblems(fixture), ['unlinked-rule.md'],
|
|
197
|
+
'a real unlinked allusion must fire the backlink safeguard');
|
|
198
|
+
} finally {
|
|
199
|
+
fs.rmSync(fixture, { recursive: true, force: true });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const requiredRule of ['skill-interface-protocol.md', 'feature-lifecycle.md']) {
|
|
203
|
+
const withoutLink = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-required-link-'));
|
|
204
|
+
try {
|
|
205
|
+
fs.cpSync(RULE_DIR, withoutLink, { recursive: true });
|
|
206
|
+
const requiredPath = path.join(withoutLink, requiredRule);
|
|
207
|
+
fs.writeFileSync(requiredPath, read(requiredPath).replace(
|
|
208
|
+
/\[([^\]]+)\]\((?:\.\/)?cost-of-detection-ladder\.md\)/i,
|
|
209
|
+
'$1'));
|
|
210
|
+
assert.deepEqual(backlinkProblems(withoutLink), [requiredRule],
|
|
211
|
+
`removing the ADR-required link from ${requiredRule} must fire the backlink safeguard`);
|
|
212
|
+
} finally {
|
|
213
|
+
fs.rmSync(withoutLink, { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('P2 — self-containment gate rejects a real internal-path leak fixture', () => {
|
|
219
|
+
const canonical = read(RULE_PATH);
|
|
220
|
+
assert.deepEqual(privateMarkerHits(canonical), []);
|
|
221
|
+
const leaked = canonical + '\nSee .claude/rules/feature-adr-conventions.md for details.\n';
|
|
222
|
+
assert.ok(privateMarkerHits(leaked).length > 0,
|
|
223
|
+
'the literal private conventions path must fire the self-containment safeguard');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('P3 — both book-grounded decisions are recorded and reflected in the template', () => {
|
|
227
|
+
const canonical = read(RULE_PATH);
|
|
228
|
+
assert.deepEqual(bookReinforcementProblems(canonical), []);
|
|
229
|
+
|
|
230
|
+
const withoutKind = canonical.replace(
|
|
231
|
+
/\n## Choose the check kind from the signal[\s\S]*?(?=\n## )/, '');
|
|
232
|
+
assert.ok(bookReinforcementProblems(withoutKind).length > 0,
|
|
233
|
+
'removing check-kind-by-signal must fire the decision guard');
|
|
234
|
+
|
|
235
|
+
const withoutReaction = canonical.replace(
|
|
236
|
+
/\n## Close the loop with a reaction[\s\S]*?(?=\n## )/, '');
|
|
237
|
+
assert.ok(bookReinforcementProblems(withoutReaction).length > 0,
|
|
238
|
+
'removing the mandatory Reaction contract must fire the decision guard');
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('P4 — rule registry rejects a real orphan and fresh init ships the ladder', () => {
|
|
242
|
+
const { COMPONENTS } = require(path.join(PKG_DIR, 'src', 'utils.js'));
|
|
243
|
+
const keys = Object.keys(COMPONENTS.rules.items);
|
|
244
|
+
assert.deepEqual(registryProblems(RULE_DIR, keys), []);
|
|
245
|
+
|
|
246
|
+
const orphanDir = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-orphan-'));
|
|
247
|
+
try {
|
|
248
|
+
fs.cpSync(RULE_DIR, orphanDir, { recursive: true });
|
|
249
|
+
fs.writeFileSync(path.join(orphanDir, 'orphan.md'), '# Orphan\n');
|
|
250
|
+
assert.ok(registryProblems(orphanDir, keys).length > 0,
|
|
251
|
+
'a real orphan Markdown rule must fire the registry safeguard');
|
|
252
|
+
} finally {
|
|
253
|
+
fs.rmSync(orphanDir, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
assert.ok(registryProblems(RULE_DIR, keys.filter((key) => key !== 'cost-of-detection-ladder')).length > 0,
|
|
257
|
+
'a cloned registry missing the ladder slug must fire the registry safeguard');
|
|
258
|
+
|
|
259
|
+
const consumer = fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-ladder-consumer-'));
|
|
260
|
+
try {
|
|
261
|
+
execFileSync(process.execPath, [CLI, 'init'], { cwd: consumer, stdio: 'pipe' });
|
|
262
|
+
const installed = read(path.join(consumer, '.claude', 'rules', RULE_FILE));
|
|
263
|
+
assert.deepEqual(ladderProblems(installed), []);
|
|
264
|
+
} finally {
|
|
265
|
+
fs.rmSync(consumer, { recursive: true, force: true });
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('P5 — package runner and scope guards reject omitted and out-of-scope inputs', () => {
|
|
270
|
+
const pkg = JSON.parse(read(path.join(PKG_DIR, 'package.json')));
|
|
271
|
+
assert.deepEqual(runnerProblems(pkg), []);
|
|
272
|
+
|
|
273
|
+
for (const script of ['test', 'test:unit']) {
|
|
274
|
+
const omitted = JSON.parse(JSON.stringify(pkg));
|
|
275
|
+
omitted.scripts[script] = omitted.scripts[script].replace(TEST_PATH, '');
|
|
276
|
+
assert.deepEqual(runnerProblems(omitted), [script],
|
|
277
|
+
`removing the focused file from the ${script} runner must fire the safeguard`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const allowed = [
|
|
281
|
+
'packages/@dzhechkov/p-replicator/templates/.claude/rules/cost-of-detection-ladder.md',
|
|
282
|
+
'features/ship-detection-ladder/07_code_changes/change_manifest.md',
|
|
283
|
+
];
|
|
284
|
+
assert.deepEqual(scopeProblems(allowed), []);
|
|
285
|
+
assert.deepEqual(
|
|
286
|
+
scopeProblems([...allowed, 'packages/@dzhechkov/harness-cli/src/forbidden.js']),
|
|
287
|
+
['packages/@dzhechkov/harness-cli/src/forbidden.js'],
|
|
288
|
+
'a real out-of-scope package path must fire the scope safeguard');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('A2 — package files include templates and the actual npm pack listing contains the ladder', async () => {
|
|
292
|
+
const pkg = JSON.parse(read(path.join(PKG_DIR, 'package.json')));
|
|
293
|
+
assert.deepEqual(packagingProblems(pkg), []);
|
|
294
|
+
const omitted = JSON.parse(JSON.stringify(pkg));
|
|
295
|
+
omitted.files = omitted.files.filter((entry) => entry !== 'templates/');
|
|
296
|
+
assert.deepEqual(packagingProblems(omitted), ['package files[] omits templates/'],
|
|
297
|
+
'a cloned files[] without templates/ must fire the distribution safeguard');
|
|
298
|
+
const packedFiles = await npmPackDryRunFiles();
|
|
299
|
+
assert.ok(
|
|
300
|
+
packedFiles.includes(`templates/.claude/rules/${RULE_FILE}`),
|
|
301
|
+
`actual npm pack listing omits templates/.claude/rules/${RULE_FILE}`);
|
|
302
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
|
|
8
|
+
const PKG_DIR = path.resolve(__dirname, '..', '..');
|
|
9
|
+
|
|
10
|
+
test('the thirteen-rule contract retains the detection ladder', () => {
|
|
11
|
+
const { COMPONENTS } = require(path.join(PKG_DIR, 'src', 'utils.js'));
|
|
12
|
+
const rules = Object.keys(COMPONENTS.rules.items);
|
|
13
|
+
assert.equal(rules.length, 13, 'the pre-shipped contract must require thirteen rules');
|
|
14
|
+
assert.ok(rules.includes('cost-of-detection-ladder'),
|
|
15
|
+
'doctor and verify must require cost-of-detection-ladder');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('P13 - PR-022 writer guards are registered for targeted mutation proof', () => {
|
|
19
|
+
const registry = JSON.parse(fs.readFileSync(
|
|
20
|
+
path.join(PKG_DIR, 'test', 'mutation-registry.json'), 'utf8'));
|
|
21
|
+
const expected = {
|
|
22
|
+
'harvest-required-writer-receipt': {
|
|
23
|
+
file: 'templates/.claude/commands/harvest.md', test: 'P1 - quick and full reject',
|
|
24
|
+
},
|
|
25
|
+
'harvest-first-write-creates-missing-carrier': {
|
|
26
|
+
file: 'templates/.claude/hooks/write-insight.cjs', test: 'P2 - writer creates',
|
|
27
|
+
},
|
|
28
|
+
'harvest-exact-repeat-is-idempotent': {
|
|
29
|
+
file: 'templates/.claude/hooks/write-insight.cjs', test: 'P2 - writer creates',
|
|
30
|
+
},
|
|
31
|
+
'session-insights-missing-is-visible': {
|
|
32
|
+
file: 'templates/.claude/hooks/session-insights.cjs', test: 'P8 - missing store triggers',
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
for (const [id, contract] of Object.entries(expected)) {
|
|
37
|
+
const entry = registry.entries.find((candidate) => candidate.id === id);
|
|
38
|
+
assert.ok(entry, `missing targeted mutation id ${id}`);
|
|
39
|
+
assert.equal(entry.file, contract.file, `${id} mutates the wrong production surface`);
|
|
40
|
+
assert.ok(entry.property && entry.property.length > 40,
|
|
41
|
+
`${id} needs a behavioral property, not a cosmetic label`);
|
|
42
|
+
assert.ok(entry.mutation?.find && entry.mutation?.replace,
|
|
43
|
+
`${id} needs a real source mutation selected by --only`);
|
|
44
|
+
assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
|
|
45
|
+
`${id} must require at least one failing behavioral test`);
|
|
46
|
+
assert.match(entry.property, new RegExp(contract.test.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
|
47
|
+
`${id} must name the real-input confirmation test that kills it`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
assert.equal(registry.entries.filter((entry) => expected[entry.id]).length, 4,
|
|
51
|
+
'all four and only the planned PR-022 guards must resolve by exact id');
|
|
52
|
+
});
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { test, describe } = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
11
|
+
const CLI = path.join(PKG, 'bin', 'cli.js');
|
|
12
|
+
const DOCTOR = path.join(PKG, 'src', 'commands', 'doctor.js');
|
|
13
|
+
const REFERENCE_TIME = new Date(2026, 7, 30, 12, 0, 0);
|
|
14
|
+
const WINDOW_DATES = ['2026-08-28', '2026-08-29', '2026-08-30'];
|
|
15
|
+
|
|
16
|
+
function tempProject(prefix = 'p-rep-doctor-flow-') {
|
|
17
|
+
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
18
|
+
execFileSync(process.execPath, [CLI, 'init'], { cwd: root, stdio: 'ignore' });
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cleanup(root) {
|
|
23
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function git(root, args, options = {}) {
|
|
27
|
+
const result = spawnSync('git', args, {
|
|
28
|
+
cwd: root,
|
|
29
|
+
encoding: 'utf8',
|
|
30
|
+
stdio: options.stdio || 'pipe',
|
|
31
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
32
|
+
});
|
|
33
|
+
assert.equal(result.status, 0,
|
|
34
|
+
`git ${args.join(' ')} failed: ${result.stderr || result.error || ''}`);
|
|
35
|
+
return result.stdout || '';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function initGit(root) {
|
|
39
|
+
git(root, ['init', '-q']);
|
|
40
|
+
git(root, ['config', 'user.name', 'P-Replicator Test']);
|
|
41
|
+
git(root, ['config', 'user.email', 'p-replicator@example.invalid']);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let commitSequence = 0;
|
|
45
|
+
function commit(root, subject, date) {
|
|
46
|
+
commitSequence += 1;
|
|
47
|
+
fs.writeFileSync(path.join(root, `.doctor-flow-${commitSequence}.txt`), `${subject}\n${date}\n`);
|
|
48
|
+
git(root, ['add', '-A']);
|
|
49
|
+
const env = {
|
|
50
|
+
GIT_AUTHOR_DATE: `${date}T12:00:00`,
|
|
51
|
+
GIT_COMMITTER_DATE: `${date}T12:00:00`,
|
|
52
|
+
};
|
|
53
|
+
git(root, ['commit', '-qm', subject], { env });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeInsights(root, records) {
|
|
57
|
+
const index = path.join(root, '.claude', 'insights', 'index.md');
|
|
58
|
+
fs.mkdirSync(path.dirname(index), { recursive: true });
|
|
59
|
+
fs.writeFileSync(index, ['# Project Insights', '', ...records.map(({ date, title }, i) => [
|
|
60
|
+
`## ${date} — ${title}`,
|
|
61
|
+
'',
|
|
62
|
+
'**Problem:**',
|
|
63
|
+
`Problem ${i + 1}.`,
|
|
64
|
+
'',
|
|
65
|
+
'**Solution:**',
|
|
66
|
+
`Solution ${i + 1}.`,
|
|
67
|
+
'',
|
|
68
|
+
'---',
|
|
69
|
+
'',
|
|
70
|
+
].join('\n'))].join('\n'));
|
|
71
|
+
return index;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runDoctor(root) {
|
|
75
|
+
delete require.cache[require.resolve(DOCTOR)];
|
|
76
|
+
const doctor = require(DOCTOR);
|
|
77
|
+
const lines = [];
|
|
78
|
+
const originalLog = console.log;
|
|
79
|
+
const previousExitCode = process.exitCode;
|
|
80
|
+
console.log = (...args) => lines.push(args.map(String).join(' '));
|
|
81
|
+
process.exitCode = undefined;
|
|
82
|
+
try {
|
|
83
|
+
doctor({ targetDir: root, now: REFERENCE_TIME });
|
|
84
|
+
return { code: process.exitCode ?? 0, out: lines.join('\n') + '\n' };
|
|
85
|
+
} finally {
|
|
86
|
+
console.log = originalLog;
|
|
87
|
+
process.exitCode = previousExitCode;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function flowLines(out) {
|
|
92
|
+
return out.split('\n').filter((line) => /Insight flow/i.test(line));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertWindow(line) {
|
|
96
|
+
assert.match(line, /2026-08-28/);
|
|
97
|
+
assert.match(line, /2026-08-30/);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function measuredProject(prefix) {
|
|
101
|
+
const root = tempProject(prefix);
|
|
102
|
+
initGit(root);
|
|
103
|
+
commit(root, 'feat: establish project history', '2026-08-27');
|
|
104
|
+
return root;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe('doctor insight-flow observation', () => {
|
|
108
|
+
test('P1 reports one shared three-day window with structural fix and insight counts', () => {
|
|
109
|
+
const root = measuredProject();
|
|
110
|
+
try {
|
|
111
|
+
commit(root, 'fix: first in-window repair', WINDOW_DATES[0]);
|
|
112
|
+
commit(root, 'fix(api): second in-window repair', WINDOW_DATES[1]);
|
|
113
|
+
commit(root, 'fix: third in-window repair', WINDOW_DATES[2]);
|
|
114
|
+
commit(root, 'feat: fix wording in documentation', WINDOW_DATES[2]);
|
|
115
|
+
commit(root, 'docs: describe fix commits', WINDOW_DATES[2]);
|
|
116
|
+
commit(root, 'fix: outside the window', '2026-08-31');
|
|
117
|
+
writeInsights(root, [
|
|
118
|
+
{ date: '2026-08-27', title: 'Outside before' },
|
|
119
|
+
{ date: WINDOW_DATES[0], title: 'First in window' },
|
|
120
|
+
{ date: WINDOW_DATES[2], title: 'Second in window' },
|
|
121
|
+
{ date: '2026-08-31', title: 'Outside after' },
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
const result = runDoctor(root);
|
|
125
|
+
const lines = flowLines(result.out);
|
|
126
|
+
assert.equal(lines.length, 1, result.out);
|
|
127
|
+
assertWindow(lines[0]);
|
|
128
|
+
assert.match(lines[0], /3 fix commits/);
|
|
129
|
+
assert.match(lines[0], /2 insight records/);
|
|
130
|
+
assert.equal(result.code, 0, result.out);
|
|
131
|
+
} finally { cleanup(root); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('P2 reports A1 no repository as not performed without changing exit code', () => {
|
|
135
|
+
const root = tempProject();
|
|
136
|
+
try {
|
|
137
|
+
const result = runDoctor(root);
|
|
138
|
+
const lines = flowLines(result.out);
|
|
139
|
+
assert.equal(lines.length, 1, result.out);
|
|
140
|
+
assert.match(lines[0], /check NOT performed/);
|
|
141
|
+
assert.match(lines[0], /not a git repository/);
|
|
142
|
+
assert.doesNotMatch(lines[0], /0 fix commits/);
|
|
143
|
+
assert.equal(result.code, 0, result.out);
|
|
144
|
+
} finally { cleanup(root); }
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('P3 reports A2 no readable history as not performed without changing exit code', () => {
|
|
148
|
+
const root = tempProject();
|
|
149
|
+
try {
|
|
150
|
+
initGit(root);
|
|
151
|
+
const result = runDoctor(root);
|
|
152
|
+
const lines = flowLines(result.out);
|
|
153
|
+
assert.equal(lines.length, 1, result.out);
|
|
154
|
+
assert.match(lines[0], /check NOT performed/);
|
|
155
|
+
assert.match(lines[0], /no readable git history/);
|
|
156
|
+
assert.doesNotMatch(lines[0], /0 fix commits/);
|
|
157
|
+
assert.equal(result.code, 0, result.out);
|
|
158
|
+
} finally { cleanup(root); }
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('P4 treats A3 missing insights carrier as measured zero', () => {
|
|
162
|
+
const root = measuredProject();
|
|
163
|
+
try {
|
|
164
|
+
commit(root, 'fix: measured without a carrier', WINDOW_DATES[1]);
|
|
165
|
+
const result = runDoctor(root);
|
|
166
|
+
const line = flowLines(result.out)[0] || '';
|
|
167
|
+
assertWindow(line);
|
|
168
|
+
assert.match(line, /1 fix commit(?:s)?/);
|
|
169
|
+
assert.match(line, /0 insight records/);
|
|
170
|
+
assert.doesNotMatch(line, /NOT performed/);
|
|
171
|
+
assert.equal(result.code, 0, result.out);
|
|
172
|
+
} finally { cleanup(root); }
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test('P5 treats A4 zero fix commits as measured zero', () => {
|
|
176
|
+
const root = measuredProject();
|
|
177
|
+
try {
|
|
178
|
+
commit(root, 'feat: in-window non-fix work', WINDOW_DATES[1]);
|
|
179
|
+
writeInsights(root, [{ date: WINDOW_DATES[1], title: 'One measured insight' }]);
|
|
180
|
+
const result = runDoctor(root);
|
|
181
|
+
const line = flowLines(result.out)[0] || '';
|
|
182
|
+
assertWindow(line);
|
|
183
|
+
assert.match(line, /0 fix commits/);
|
|
184
|
+
assert.match(line, /1 insight record(?:s)?/);
|
|
185
|
+
assert.doesNotMatch(line, /NOT performed/);
|
|
186
|
+
assert.equal(result.code, 0, result.out);
|
|
187
|
+
} finally { cleanup(root); }
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('P6 keeps claimant-shaped 19/0 evidence neutral and non-gating', () => {
|
|
191
|
+
const root = measuredProject();
|
|
192
|
+
try {
|
|
193
|
+
for (let i = 1; i <= 19; i += 1) {
|
|
194
|
+
commit(root, `fix: claimant-shaped repair ${i}`, WINDOW_DATES[(i - 1) % 3]);
|
|
195
|
+
}
|
|
196
|
+
const result = runDoctor(root);
|
|
197
|
+
const lines = flowLines(result.out);
|
|
198
|
+
assert.equal(lines.length, 1, result.out);
|
|
199
|
+
assertWindow(lines[0]);
|
|
200
|
+
assert.match(lines[0], /19 fix commits/);
|
|
201
|
+
assert.match(lines[0], /0 insight records/);
|
|
202
|
+
assert.doesNotMatch(lines[0], /healthy|clean|few|warning|problem|broken|unused|capture/i);
|
|
203
|
+
assert.doesNotMatch(lines[0], /[✓✗⚠!]/);
|
|
204
|
+
assert.equal(result.code, 0, result.out);
|
|
205
|
+
} finally { cleanup(root); }
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('P7 re-reads git and insights on each invocation', () => {
|
|
209
|
+
const root = measuredProject();
|
|
210
|
+
try {
|
|
211
|
+
commit(root, 'fix: first live repair', WINDOW_DATES[0]);
|
|
212
|
+
const index = writeInsights(root,
|
|
213
|
+
[{ date: WINDOW_DATES[0], title: 'First live insight' }]);
|
|
214
|
+
const first = flowLines(runDoctor(root).out)[0] || '';
|
|
215
|
+
assert.match(first, /1 fix commit(?:s)?/);
|
|
216
|
+
assert.match(first, /1 insight record(?:s)?/);
|
|
217
|
+
|
|
218
|
+
commit(root, 'fix(core): second live repair', WINDOW_DATES[2]);
|
|
219
|
+
fs.appendFileSync(index, [
|
|
220
|
+
`## ${WINDOW_DATES[2]} — Second live insight`,
|
|
221
|
+
'',
|
|
222
|
+
'**Problem:** Changed after the first doctor run.',
|
|
223
|
+
'',
|
|
224
|
+
'**Solution:** Read the carrier again.',
|
|
225
|
+
'',
|
|
226
|
+
].join('\n'));
|
|
227
|
+
const second = flowLines(runDoctor(root).out)[0] || '';
|
|
228
|
+
assert.match(second, /2 fix commits/);
|
|
229
|
+
assert.match(second, /2 insight records/);
|
|
230
|
+
} finally { cleanup(root); }
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('P8 reports an unreadable insight carrier as not performed', () => {
|
|
234
|
+
const root = measuredProject();
|
|
235
|
+
try {
|
|
236
|
+
commit(root, 'fix: carrier failure remains unknown', WINDOW_DATES[1]);
|
|
237
|
+
const index = path.join(root, '.claude', 'insights', 'index.md');
|
|
238
|
+
fs.mkdirSync(index, { recursive: true });
|
|
239
|
+
const result = runDoctor(root);
|
|
240
|
+
const line = flowLines(result.out)[0] || '';
|
|
241
|
+
assert.match(line, /check NOT performed/);
|
|
242
|
+
assert.match(line, /insight carrier.*unreadable/i);
|
|
243
|
+
assert.doesNotMatch(line, /insight records/);
|
|
244
|
+
assert.equal(result.code, 0, result.out);
|
|
245
|
+
} finally { cleanup(root); }
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('P9 preserves existing zero and nonzero doctor exits across flow states', () => {
|
|
249
|
+
for (const broken of [false, true]) {
|
|
250
|
+
const root = tempProject();
|
|
251
|
+
try {
|
|
252
|
+
if (broken) {
|
|
253
|
+
fs.rmSync(path.join(root, '.claude', 'rules', 'docker-ports.md'));
|
|
254
|
+
}
|
|
255
|
+
const expected = broken ? 1 : 0;
|
|
256
|
+
const noRepository = runDoctor(root);
|
|
257
|
+
assert.equal(noRepository.code, expected, noRepository.out);
|
|
258
|
+
assert.match(flowLines(noRepository.out)[0] || '', /NOT performed/);
|
|
259
|
+
|
|
260
|
+
initGit(root);
|
|
261
|
+
commit(root, 'fix: measured state', WINDOW_DATES[1]);
|
|
262
|
+
const measured = runDoctor(root);
|
|
263
|
+
assert.equal(measured.code, expected, measured.out);
|
|
264
|
+
assert.match(flowLines(measured.out)[0] || '', /1 fix commit(?:s)?/);
|
|
265
|
+
|
|
266
|
+
const index = path.join(root, '.claude', 'insights', 'index.md');
|
|
267
|
+
fs.mkdirSync(index, { recursive: true });
|
|
268
|
+
const unavailable = runDoctor(root);
|
|
269
|
+
assert.equal(unavailable.code, expected, unavailable.out);
|
|
270
|
+
assert.match(flowLines(unavailable.out)[0] || '', /NOT performed/);
|
|
271
|
+
} finally { cleanup(root); }
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('P10 package scripts include the doctor insight-flow suite', () => {
|
|
276
|
+
const pkg = require(path.join(PKG, 'package.json'));
|
|
277
|
+
for (const name of ['test', 'test:unit']) {
|
|
278
|
+
const occurrences = pkg.scripts[name]
|
|
279
|
+
.split('doctor-insight-flow.test.js').length - 1;
|
|
280
|
+
assert.equal(occurrences, 1, `${name} must run the suite exactly once`);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('P11 README describes the doctor insight-flow observation as informational and non-gating', () => {
|
|
285
|
+
const readme = fs.readFileSync(path.join(PKG, 'README.md'), 'utf8');
|
|
286
|
+
const start = readme.indexOf('### Doctor insight-flow observation');
|
|
287
|
+
const end = readme.indexOf('\n### ', start + 1);
|
|
288
|
+
assert.ok(start >= 0 && end > start, 'README must contain a bounded doctor insight-flow section');
|
|
289
|
+
const section = readme.slice(start, end);
|
|
290
|
+
assert.match(section, /three calendar days/i);
|
|
291
|
+
assert.match(section, /git history/i);
|
|
292
|
+
assert.match(section, /\.claude\/insights\/index\.md/);
|
|
293
|
+
assert.match(section, /fix commits[\s\S]*insight records/i);
|
|
294
|
+
assert.match(section, /check NOT performed/);
|
|
295
|
+
assert.match(section, /does not change[^.]*exit status/i);
|
|
296
|
+
assert.match(section, /does not interpret|no threshold/i);
|
|
297
|
+
assert.doesNotMatch(section, /19\s+fix|0\s+insight/i,
|
|
298
|
+
'claimant field figures are not locally reproduced product evidence');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('P12 uses argument-array git execution for metacharacter paths and subjects', () => {
|
|
302
|
+
const root = measuredProject('p rep ; touch P12_SIDE_EFFECT ; ');
|
|
303
|
+
try {
|
|
304
|
+
commit(root, 'fix(shell): $(touch P12_SUBJECT_SIDE_EFFECT)', WINDOW_DATES[1]);
|
|
305
|
+
const result = runDoctor(root);
|
|
306
|
+
const line = flowLines(result.out)[0] || '';
|
|
307
|
+
assert.match(line, /1 fix commit(?:s)?/);
|
|
308
|
+
assert.equal(fs.existsSync(path.join(root, 'P12_SIDE_EFFECT')), false,
|
|
309
|
+
'targetDir metacharacters must never reach a shell');
|
|
310
|
+
assert.equal(fs.existsSync(path.join(root, 'P12_SUBJECT_SIDE_EFFECT')), false,
|
|
311
|
+
'commit subjects are data, never executable text');
|
|
312
|
+
assert.equal(result.code, 0, result.out);
|
|
313
|
+
} finally { cleanup(root); }
|
|
314
|
+
});
|
|
315
|
+
});
|