@dzhechkov/p-replicator 1.12.0 → 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 +225 -61
- package/CHANGELOG.md +148 -1
- package/LICENSE +21 -0
- package/MULTIPLATFORM_ROADMAP.md +1 -1
- package/README/eng/01_quickstart.md +2 -2
- package/README/eng/02_user_guide.md +1 -1
- package/README/eng/03_admin_guide.md +2 -2
- package/README/eng/05_architecture.md +1 -1
- package/README/eng/README.md +2 -1
- package/README/ru/01_quickstart.md +2 -2
- package/README/ru/02_user_guide.md +1 -1
- package/README/ru/03_admin_guide.md +2 -2
- package/README/ru/05_architecture.md +1 -1
- package/README/ru/README.md +2 -1
- package/README/ru/html/index.html +8 -8
- package/README.md +132 -9
- package/bin/cli.js +0 -0
- package/package.json +10 -11
- package/sbom.json +470 -60
- package/scripts/check-pipeline-gaps.sh +0 -0
- package/src/commands/init.js +1 -1
- package/src/rule-components.json +5 -1
- package/src/utils.js +32 -3
- package/templates/.claude/agents/product-discoverer.md +38 -0
- package/templates/.claude/agents/replicate-coordinator.md +11 -1
- package/templates/.claude/commands/feature.md +29 -5
- package/templates/.claude/commands/go.md +6 -8
- package/templates/.claude/commands/harvest.md +5 -7
- package/templates/.claude/commands/replicate.md +169 -44
- package/templates/.claude/commands/start.md +28 -7
- 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 +27 -6
- 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/statusline.cjs +2 -2
- package/templates/.claude/rules/embeddable-widget.md +73 -0
- package/templates/.claude/rules/feature-lifecycle.md +5 -6
- package/templates/.claude/rules/incoming-webhooks.md +99 -0
- 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 +121 -52
- 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/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 +173 -725
- package/tests/snapshot/baseline.json +60 -38
- 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-ports.test.js +46 -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 +8 -2
- package/tests/unit/detection-ladder-registry.test.js +2 -2
- 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 +91 -9
- 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/utils.test.js +40 -3
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 0.5 (Source Product Profile) is a SECTION IN MARKDOWN that a model reads and executes —
|
|
4
|
+
// there is no deterministic phase engine in this package. So the deterministic layer available for
|
|
5
|
+
// the phase itself is the CONTENT of the three files that declare the sequence, and its ORDER.
|
|
6
|
+
//
|
|
7
|
+
// Why order is an assertion and not decoration: the phase's whole justification is WHERE it sits.
|
|
8
|
+
// Phase 0 is skipped entirely by the `--from-docs` entry, so a look-capture placed inside Phase 0
|
|
9
|
+
// would switch itself off for exactly the projects that arrive with someone else's documentation —
|
|
10
|
+
// which are, more often than not, replications of someone else's product. Move the section back
|
|
11
|
+
// inside Phase 0 and this file goes red; that is the point.
|
|
12
|
+
//
|
|
13
|
+
// The sequence is declared in THREE places and they must agree. Every assertion below is
|
|
14
|
+
// DISCRIMINATING: remove what it names and it goes red.
|
|
15
|
+
|
|
16
|
+
const { test, describe } = require('node:test');
|
|
17
|
+
const assert = require('node:assert/strict');
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const TPL = path.join(__dirname, '..', '..', 'templates', '.claude');
|
|
22
|
+
const read = (...p) => fs.readFileSync(path.join(TPL, ...p), 'utf-8');
|
|
23
|
+
|
|
24
|
+
const CMD = read('commands', 'replicate.md');
|
|
25
|
+
const RULE = read('rules', 'replicate-pipeline.md');
|
|
26
|
+
const AGENT = read('agents', 'replicate-coordinator.md');
|
|
27
|
+
const CJM = read('skills', 'reverse-engineering-unicorn', 'modules', '025-cjm-prototype.md');
|
|
28
|
+
|
|
29
|
+
const PROFILE_PATH = 'docs/source-product-profile.md';
|
|
30
|
+
const CHECKER = 'check-look-trace.cjs';
|
|
31
|
+
const CAPTURE = 'capture-source-path.cjs';
|
|
32
|
+
/** The reasons a NAMED source may go uncaptured. Closed, because each names a DIFFERENT fix.
|
|
33
|
+
* The last four arrived with the browser instrument for the `путь` axis. */
|
|
34
|
+
const REASONS = ['no-browser-mcp', 'unreachable', 'auth-required', 'out-of-scope',
|
|
35
|
+
'no-browser', 'bot-protected', 'timeout', 'robots-disallowed'];
|
|
36
|
+
|
|
37
|
+
describe('Phase 0.5 is pinned where it must live, in all three places that declare the sequence', () => {
|
|
38
|
+
test('P1 - ORDER: the Phase 0.5 section sits between the Phase 0 checkpoint and Phase 1', () => {
|
|
39
|
+
// The first of three independent ways this file goes red when the phase is switched off.
|
|
40
|
+
const checkpoint0 = CMD.indexOf('✅ PHASE 0: PRODUCT DISCOVERY');
|
|
41
|
+
const phase05 = CMD.indexOf('### Phase 0.5: SOURCE PRODUCT PROFILE');
|
|
42
|
+
const phase1 = CMD.indexOf('### Phase 1: PLANNING');
|
|
43
|
+
|
|
44
|
+
assert.ok(checkpoint0 > 0, 'replicate.md must still carry the Phase 0 checkpoint');
|
|
45
|
+
assert.ok(phase1 > 0, 'replicate.md must still carry Phase 1');
|
|
46
|
+
assert.ok(phase05 > 0, 'replicate.md must carry a Phase 0.5 section heading');
|
|
47
|
+
assert.ok(phase05 > checkpoint0,
|
|
48
|
+
'Phase 0.5 must come AFTER the Phase 0 checkpoint — placed before it, it would be part of '
|
|
49
|
+
+ 'the optional phase and would vanish with it');
|
|
50
|
+
assert.ok(phase05 < phase1,
|
|
51
|
+
'Phase 0.5 must come BEFORE Phase 1 — Phase 1 promotes its FR-LOOK rows, so a phase running '
|
|
52
|
+
+ 'after Phase 1 would have nothing to promote into');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('P2 - the phase is MANDATORY and explicitly survives --from-docs', () => {
|
|
56
|
+
// The second independent way. This is the whole reason the phase is separate; a Phase 0.5 that
|
|
57
|
+
// the alternative entry also skipped would be the defect wearing the fix's clothes.
|
|
58
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
59
|
+
assert.match(head, /обязательная|ВСЕГДА/,
|
|
60
|
+
'the section heading/lead must say the phase always runs');
|
|
61
|
+
assert.match(head, /--from-docs|--skip-discovery/,
|
|
62
|
+
'and it must name the entry that skips Phase 0, so the reader knows what it survives');
|
|
63
|
+
|
|
64
|
+
// And the alternative-entry section itself must say so, because a reader who lands there is
|
|
65
|
+
// reading the list of what runs and what does not.
|
|
66
|
+
const alt = CMD.slice(CMD.indexOf('### Modified pipeline flow when triggered'),
|
|
67
|
+
CMD.indexOf('### Three sub-paths'));
|
|
68
|
+
assert.ok(alt.length > 0, 'replicate.md must still carry the modified-flow list');
|
|
69
|
+
assert.match(alt, /\*\*Phase 0\.5\*\*/, 'the modified-flow list must name Phase 0.5');
|
|
70
|
+
assert.match(alt, /RUNS/,
|
|
71
|
+
'and must say it RUNS — a list that names the phase without saying so reads as a skip');
|
|
72
|
+
|
|
73
|
+
const altRule = RULE.slice(RULE.indexOf('### Behavior'), RULE.indexOf('### Three supported sub-paths'));
|
|
74
|
+
assert.ok(altRule.length > 0, 'the rule must still carry the alternative-entry behaviour list');
|
|
75
|
+
assert.match(altRule, /Phase 0\.5 STILL RUNS/i,
|
|
76
|
+
'the rule\'s behaviour list must say Phase 0.5 still runs on the --from-docs entry');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('P3 - THREE PLACES agree on the sequence: command, rule, coordinator agent', () => {
|
|
80
|
+
// The third independent way. These three declare the phase order; a change to one alone is a
|
|
81
|
+
// second-generation lie — the pipeline documented as doing something two of its own files
|
|
82
|
+
// never mention.
|
|
83
|
+
assert.match(CMD, /Phase 0\.5/, 'replicate.md must declare Phase 0.5');
|
|
84
|
+
assert.match(RULE, /Phase 0\.5/, 'replicate-pipeline.md must declare Phase 0.5');
|
|
85
|
+
assert.match(AGENT, /Phase 0\.5|0\.5/, 'replicate-coordinator.md must declare Phase 0.5');
|
|
86
|
+
|
|
87
|
+
// The rule's own sequence diagram, not merely a mention somewhere in the file.
|
|
88
|
+
const seq = RULE.slice(RULE.indexOf('## Phase Sequence'), RULE.indexOf('## Skill Loading Protocol'));
|
|
89
|
+
assert.match(seq, /Phase 0\.5/, 'the Phase Sequence block itself must contain Phase 0.5');
|
|
90
|
+
assert.match(seq, /always|ALWAYS|всегда/,
|
|
91
|
+
'and must mark it as the one that always runs, next to the optional Phase 0');
|
|
92
|
+
|
|
93
|
+
// The coordinator's own numbered sequence.
|
|
94
|
+
assert.match(AGENT, /0\s*→\s*0\.5\s*→\s*1\s*→\s*2\s*→\s*3\s*→\s*4/,
|
|
95
|
+
'the coordinator must carry the full ordered sequence including 0.5');
|
|
96
|
+
|
|
97
|
+
// All three must name the SAME artifact, or "the profile" means three different files.
|
|
98
|
+
for (const [name, text] of [['replicate.md', CMD], ['replicate-pipeline.md', RULE],
|
|
99
|
+
['replicate-coordinator.md', AGENT]]) {
|
|
100
|
+
assert.ok(text.includes(PROFILE_PATH), name + ' must name ' + PROFILE_PATH);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('P4 - THREE outcomes are declared, and the third is the one with a closed reason list', () => {
|
|
105
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
106
|
+
for (const outcome of ['СНЯТ', 'НЕ ИЗМЕРЕНО', 'ИСТОЧНИКА НЕТ']) {
|
|
107
|
+
assert.ok(head.includes(outcome), 'the phase must declare the outcome ' + outcome);
|
|
108
|
+
}
|
|
109
|
+
for (const reason of REASONS) {
|
|
110
|
+
assert.ok(head.includes(reason),
|
|
111
|
+
'the closed reason list must be spelled out in the phase: missing ' + reason);
|
|
112
|
+
}
|
|
113
|
+
// Two outcomes would be the defect: "captured" and "not captured" collapses "we looked and there
|
|
114
|
+
// is nothing to look at" into "we could not look".
|
|
115
|
+
assert.match(head, /ТРИ исхода|три исхода/,
|
|
116
|
+
'the phase must say there are THREE outcomes, not two');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('P5 - the phase carries its own deterministic gate with three exit codes', () => {
|
|
120
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
121
|
+
assert.ok(head.includes(CHECKER), 'the phase must name its checker: ' + CHECKER);
|
|
122
|
+
assert.match(head, /проверка НЕ выполнена|НЕ выполнена/,
|
|
123
|
+
'exit 2 must be described as "the check did not run", never as a pass');
|
|
124
|
+
assert.ok(fs.existsSync(path.join(TPL, 'hooks', CHECKER)),
|
|
125
|
+
'and the checker the text names must actually ship');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('P6 - Phase 1 is told to promote FR-LOOK rows, or the seed has no destination', () => {
|
|
129
|
+
const phase1 = CMD.slice(CMD.indexOf('### Phase 1: PLANNING'),
|
|
130
|
+
CMD.indexOf('### Прерванный прогон'));
|
|
131
|
+
assert.match(phase1, /FR-LOOK/, 'Phase 1 must name the FR-LOOK family it promotes');
|
|
132
|
+
assert.match(phase1, /Specification\.md/, 'and the file it promotes them into');
|
|
133
|
+
assert.match(phase1, /FR-GROWTH/,
|
|
134
|
+
'both seed families are promoted by the same step — naming only one invites a second dialect');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('P7 - the industry palette is a FALLBACK, not an equal branch', () => {
|
|
138
|
+
// The defect this feature removes: a pipeline whose job is to reproduce a product invented its
|
|
139
|
+
// palette from the industry. The table stays — it is the right answer when there is no source —
|
|
140
|
+
// but it must be reachable only through the "no source / not measured" states.
|
|
141
|
+
const table = CJM.indexOf('| Health/Fitness | Emerald/Teal');
|
|
142
|
+
assert.ok(table > 0, 'the industry table must still exist — it is the legitimate fallback');
|
|
143
|
+
|
|
144
|
+
const precedence = CJM.indexOf('Порядок приоритета');
|
|
145
|
+
assert.ok(precedence > 0, 'a precedence block must exist above the table');
|
|
146
|
+
assert.ok(precedence < table,
|
|
147
|
+
'the precedence block must come BEFORE the table: a rule stated after the table is a rule the '
|
|
148
|
+
+ 'reader meets after already using it');
|
|
149
|
+
|
|
150
|
+
const between = CJM.slice(precedence, table);
|
|
151
|
+
assert.match(between, /СНЯТ/, 'the captured state must be named');
|
|
152
|
+
assert.match(between, /НЕ ИЗМЕРЕН/, 'the not-measured state must be named');
|
|
153
|
+
assert.match(between, /ФОЛБЭК|фолбэк/, 'the table must be labelled a fallback');
|
|
154
|
+
|
|
155
|
+
// The table's own caption must carry the condition, because a reader who jumps to the table
|
|
156
|
+
// sees the caption and nothing above it.
|
|
157
|
+
const caption = CJM.slice(CJM.lastIndexOf('**Design адаптация', table), table);
|
|
158
|
+
assert.match(caption, /ФОЛБЭК/,
|
|
159
|
+
'the table caption itself must say it is the fallback, not only the paragraph above it');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('P8 - the capture skill is CALLED, never vendored into this package', () => {
|
|
163
|
+
// ADR-0001 + sources.json: clone-website has one canonical home, in its own package. Vendoring
|
|
164
|
+
// it here would add an eleventh skill to a contract pinned in four places.
|
|
165
|
+
const shipped = fs.readdirSync(path.join(TPL, 'skills'));
|
|
166
|
+
assert.ok(!shipped.includes('clone-website'),
|
|
167
|
+
'clone-website must NOT be vendored into templates/.claude/skills/: ' + shipped.join(', '));
|
|
168
|
+
assert.match(CMD, /skills-website-cloner/,
|
|
169
|
+
'the phase must name the canonical package it calls');
|
|
170
|
+
assert.match(RULE, /skills-website-cloner/,
|
|
171
|
+
'and the rule must too, so the reference cannot drift to a copy');
|
|
172
|
+
|
|
173
|
+
// Its absence must be a legitimate outcome, not a blocked pipeline.
|
|
174
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
175
|
+
assert.match(head, /no-browser-mcp/,
|
|
176
|
+
'an unmet prerequisite must map to a named reason, not to a stalled run');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('P10 - the `путь` axis has an INSTRUMENT, and both files name it', () => {
|
|
180
|
+
// The axis existed as a column from the first day of Phase 0.5; nothing could fill it. A column
|
|
181
|
+
// with no instrument is a promise the pipeline cannot keep, and it would quietly stay empty
|
|
182
|
+
// forever — which reads exactly like "this product has no path requirements".
|
|
183
|
+
assert.ok(fs.existsSync(path.join(TPL, 'hooks', CAPTURE)), 'the instrument must ship: ' + CAPTURE);
|
|
184
|
+
const head = CMD.slice(CMD.indexOf('### Phase 0.5'), CMD.indexOf('### Phase 1: PLANNING'));
|
|
185
|
+
assert.ok(head.includes(CAPTURE), 'the phase must name the instrument of its second axis');
|
|
186
|
+
assert.ok(RULE.includes(CAPTURE), 'and the rule must too, or the two describe different phases');
|
|
187
|
+
|
|
188
|
+
// Both axes must answer for themselves. One shared status would have to lie about one of them.
|
|
189
|
+
assert.match(head, /Статус съёмки \(путь\)/,
|
|
190
|
+
'the phase must carry the second axis\'s own status line');
|
|
191
|
+
assert.match(RULE, /Статус съёмки \(путь\)/, 'and so must the rule');
|
|
192
|
+
|
|
193
|
+
// The instrument is a PRECONDITION, not a dependency: the package ships zero of those.
|
|
194
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf-8'));
|
|
195
|
+
assert.equal(pkg.dependencies, undefined,
|
|
196
|
+
'Playwright is an EXTERNAL prerequisite; putting it in dependencies would end the '
|
|
197
|
+
+ 'zero-dependency contract for a utility that honestly works without it');
|
|
198
|
+
assert.match(head + RULE, /no-browser/,
|
|
199
|
+
'and its absence must map to a named reason, not to a stalled run');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('P11 - the legality boundary lives in the RULE, not only in a report', () => {
|
|
203
|
+
// A boundary stated only where the finished work is described is a boundary nobody reads BEFORE
|
|
204
|
+
// crossing it. This one governs what may be captured at all, so it sits in the always-loaded
|
|
205
|
+
// rule, next to the instrument that could cross it.
|
|
206
|
+
const sec = RULE.slice(RULE.indexOf('### Phase 0.5'), RULE.indexOf('## Skill Loading Protocol'));
|
|
207
|
+
assert.ok(sec.length > 0, 'the rule must still carry its Phase 0.5 section');
|
|
208
|
+
assert.match(sec, /REGULARITIES|закономерн/i,
|
|
209
|
+
'the rule must say that REGULARITIES are what gets captured');
|
|
210
|
+
assert.match(sec, /VALUES|значени/i, 'and that VALUES are what does not');
|
|
211
|
+
assert.match(sec, /copyright/i, 'third-party CSS/DOM as copyrighted code must be named');
|
|
212
|
+
assert.match(sec, /trademark/i, 'the logo+name+colours-together trademark case must be named');
|
|
213
|
+
assert.match(sec, /robots\.txt/, 'reading robots.txt before a crawl must be named');
|
|
214
|
+
assert.match(sec, /FORBIDDEN|ЗАПРЕЩ/,
|
|
215
|
+
'circumvention and authentication must be refused in words, not merely discouraged');
|
|
216
|
+
assert.match(sec, /sx-ds2y8i|class names/i,
|
|
217
|
+
'and the brittleness rule — never class names — must be stated where the instrument is');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('P9 - the hook inventory in the rule matches what ships (the count is not decorative)', () => {
|
|
221
|
+
const { COMPONENTS } = require(path.join(__dirname, '..', '..', 'src', 'utils.js'));
|
|
222
|
+
const n = Object.keys(COMPONENTS.hooks.items).length;
|
|
223
|
+
const m = RULE.match(/\*\*Hooks \((\d+) files/);
|
|
224
|
+
assert.ok(m, 'replicate-pipeline.md must state the hook file count');
|
|
225
|
+
assert.equal(Number(m[1]), n,
|
|
226
|
+
'the rule advertises a hook count the package does not ship — the stale "8 files" line was '
|
|
227
|
+
+ 'exactly this defect, one generation earlier');
|
|
228
|
+
assert.ok(RULE.includes(CHECKER),
|
|
229
|
+
'and the new checker must be listed among the deliberately-invoked utilities');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The deterministic half of «отрицательный вывод структурно НЕВИДИМ для отчётного гейта». No new
|
|
4
|
+
// always-loaded rule: the corpus budget is spent (measured 2026-09-01) and the owner refused to
|
|
5
|
+
// raise the threshold, so the doctrine lives in `references/negative-results.md` — loaded on
|
|
6
|
+
// demand — plus four lines in the SKILL that a reader must carry.
|
|
7
|
+
//
|
|
8
|
+
// THE DEFECT, and how it was established. NOT by grep: grep finds the vocabulary, not the
|
|
9
|
+
// blindness. By TWIN TEST. `check_report_evidence.py` walked FROM THE LEDGER TO THE REPORT — for
|
|
10
|
+
// each recorded fact, is it used and is its use honest. That direction is structurally blind to a
|
|
11
|
+
// claim with NO SOURCE, and the strongest claim a research report makes has exactly that shape.
|
|
12
|
+
//
|
|
13
|
+
// MEASURED 2026-09-01 and RE-MEASURED by P1 below: two runs over ONE ledger, the reports differing
|
|
14
|
+
// by EXACTLY one line of unsupported negative conclusion, produced BYTE-IDENTICAL --json output,
|
|
15
|
+
// both exit 0. So an unbacked negation could neither raise nor lower the finding count.
|
|
16
|
+
//
|
|
17
|
+
// THE MECHANISM: «не встретилось» and «доказано, что нет» arrive in the same form and are decided
|
|
18
|
+
// on identically. The first is a property of the SEARCH, the second of the WORLD — and only the
|
|
19
|
+
// second justifies building a product on an empty niche.
|
|
20
|
+
//
|
|
21
|
+
// THE ACCEPTED CORRECTION (P6): a ceiling of «enumerable corpus declared exhaustive» is NOT enough,
|
|
22
|
+
// because an exhaustive LIST does not prove an exhaustive SEARCH. Method, query coverage, sampling
|
|
23
|
+
// refusals and the corpus's time boundary are part of the ceiling too.
|
|
24
|
+
|
|
25
|
+
const { test, describe } = require('node:test');
|
|
26
|
+
const assert = require('node:assert/strict');
|
|
27
|
+
const { spawnSync } = require('node:child_process');
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const os = require('node:os');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
|
|
32
|
+
const PKG = path.resolve(__dirname, '..', '..');
|
|
33
|
+
const SKILL_DIR = path.join(PKG, 'templates', '.claude', 'skills', 'goap-research-ed25519');
|
|
34
|
+
const GATE = path.join(SKILL_DIR, 'scripts', 'check_report_evidence.py');
|
|
35
|
+
|
|
36
|
+
const read = (rel) => fs.readFileSync(path.join(PKG, rel), 'utf8');
|
|
37
|
+
|
|
38
|
+
/** Is a usable python3 present? Reported honestly rather than silently skipped past. */
|
|
39
|
+
function python() {
|
|
40
|
+
for (const bin of ['python3', 'python']) {
|
|
41
|
+
const probe = spawnSync(bin, ['--version'], { encoding: 'utf8' });
|
|
42
|
+
if (probe.status === 0) return bin;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const PY = python();
|
|
47
|
+
|
|
48
|
+
/** A one-fact ledger built from the skill's own shipped fixture, so the test does not invent a
|
|
49
|
+
* ledger shape the gate might not accept. */
|
|
50
|
+
function ledger() {
|
|
51
|
+
const fixture = JSON.parse(read(path.join('templates', '.claude', 'skills',
|
|
52
|
+
'goap-research-ed25519', 'scripts', 'fixture_legacy_v2_fact.json')));
|
|
53
|
+
return JSON.stringify({ facts: [fixture.fact] });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Run the REAL gate over a report and return its parsed JSON verdict. */
|
|
57
|
+
function gate(reportText) {
|
|
58
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-neg-')));
|
|
59
|
+
try {
|
|
60
|
+
const report = path.join(dir, 'report.md');
|
|
61
|
+
const facts = path.join(dir, 'facts.json');
|
|
62
|
+
fs.writeFileSync(report, reportText);
|
|
63
|
+
fs.writeFileSync(facts, ledger());
|
|
64
|
+
const r = spawnSync(PY, [GATE, '--report', report, '--facts', facts, '--json'], { encoding: 'utf8' });
|
|
65
|
+
const raw = (r.stdout || '').trim();
|
|
66
|
+
let parsed = null;
|
|
67
|
+
try { parsed = JSON.parse(raw); } catch { /* left null, asserted by the caller */ }
|
|
68
|
+
return { code: r.status, raw, json: parsed, err: r.stderr || '' };
|
|
69
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const BASE = '# Report\n\nCompetitor A charges 10 USD per seat.\n';
|
|
73
|
+
const NEGATIVE = 'Вывод: функции нет ни у одного из трёх конкурентов; ниша свободна.\n';
|
|
74
|
+
|
|
75
|
+
/** The five fields the claim must carry, with the values a case needs. */
|
|
76
|
+
function basis({
|
|
77
|
+
corpus = 'справочные центры A (14 стр.), B (9 стр.), C (11 стр.) — перечень в приложении A',
|
|
78
|
+
completeness = 'перечислимо и объявлено исчерпывающим',
|
|
79
|
+
method = 'полнотекстовый поиск по 6 запросам: «bulk export», «экспорт», «CSV»',
|
|
80
|
+
boundary = 'по состоянию на 2026-08-30',
|
|
81
|
+
implication = 'не встретилось',
|
|
82
|
+
} = {}) {
|
|
83
|
+
const line = (label, value) => (value === null ? '' : label + ': ' + value + '\n');
|
|
84
|
+
return line('КОРПУС', corpus) + line('ПОЛНОТА', completeness) + line('СПОСОБ ПОИСКА', method)
|
|
85
|
+
+ line('ГРАНИЦА КОРПУСА', boundary) + line('СЛЕДСТВИЕ', implication);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const kinds = (v) => (v.json ? v.json.findings.map((f) => f.kind) : ['(unparseable)']);
|
|
89
|
+
|
|
90
|
+
describe('отрицательный вывод — the second direction of the report gate', () => {
|
|
91
|
+
test('P1 - THE twin test: the two reports no longer produce identical verdicts', {
|
|
92
|
+
skip: PY ? false : 'python3 is not available in this environment',
|
|
93
|
+
}, () => {
|
|
94
|
+
// The measurement that established the defect, re-run as the proof of the fix. Before the
|
|
95
|
+
// change these two were byte-identical and both exit 0; asserting the difference is what makes
|
|
96
|
+
// this a fix rather than a description of one.
|
|
97
|
+
const plain = gate(BASE);
|
|
98
|
+
const negated = gate(BASE + NEGATIVE);
|
|
99
|
+
assert.ok(plain.json, 'the gate must emit JSON: ' + plain.raw + plain.err);
|
|
100
|
+
assert.ok(negated.json, 'the gate must emit JSON: ' + negated.raw + negated.err);
|
|
101
|
+
|
|
102
|
+
assert.equal(plain.code, 0, 'the report WITHOUT the negative conclusion is still clean: ' + plain.raw);
|
|
103
|
+
assert.equal(negated.code, 1, 'the unsupported negative conclusion must now be a finding');
|
|
104
|
+
assert.notDeepEqual(plain.json, negated.json,
|
|
105
|
+
'one added line of unsupported negation must change the verdict — identical output here IS '
|
|
106
|
+
+ 'the defect, and it is what was measured on 2026-09-01');
|
|
107
|
+
assert.deepEqual(kinds(negated), ['NEGATIVE_CONCLUSION_WITHOUT_BASIS']);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('P2 - the finding NAMES the conclusion and says which fields are missing', {
|
|
111
|
+
skip: PY ? false : 'python3 is not available',
|
|
112
|
+
}, () => {
|
|
113
|
+
const v = gate(BASE + NEGATIVE);
|
|
114
|
+
const f = v.json.findings[0];
|
|
115
|
+
assert.match(f.claim, /ниша свободна/, 'the claim itself must be quoted back');
|
|
116
|
+
for (const field of ['КОРПУС', 'ПОЛНОТА', 'СПОСОБ ПОИСКА', 'ГРАНИЦА КОРПУСА', 'СЛЕДСТВИЕ']) {
|
|
117
|
+
assert.ok(f.detail.includes(field), 'the missing field must be named: ' + field);
|
|
118
|
+
}
|
|
119
|
+
// The mechanism, in words — the sentence a reader has to carry away.
|
|
120
|
+
assert.match(f.detail, /property of the\s+SEARCH/);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('P3 - the SAME conclusion WITH its full basis passes', {
|
|
124
|
+
skip: PY ? false : 'python3 is not available',
|
|
125
|
+
}, () => {
|
|
126
|
+
const v = gate(BASE + NEGATIVE + '\n' + basis());
|
|
127
|
+
assert.equal(v.code, 0, v.raw);
|
|
128
|
+
assert.deepEqual(kinds(v), []);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('P4 - each of the five fields is load-bearing on its own', {
|
|
132
|
+
skip: PY ? false : 'python3 is not available',
|
|
133
|
+
}, () => {
|
|
134
|
+
// Without this the block could be satisfied by writing four of the five and the fifth would be
|
|
135
|
+
// decoration — which is how a required field quietly becomes optional.
|
|
136
|
+
for (const field of ['corpus', 'completeness', 'method', 'boundary', 'implication']) {
|
|
137
|
+
const v = gate(BASE + NEGATIVE + '\n' + basis({ [field]: null }));
|
|
138
|
+
assert.equal(v.code, 1, 'dropping ' + field + ' must fire: ' + v.raw);
|
|
139
|
+
assert.deepEqual(kinds(v), ['NEGATIVE_CONCLUSION_WITHOUT_BASIS'], field);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('P5 - ПОЛНОТА and СЛЕДСТВИЕ are CLOSED vocabularies', {
|
|
144
|
+
skip: PY ? false : 'python3 is not available',
|
|
145
|
+
}, () => {
|
|
146
|
+
// An open field here collects a word that sounds thorough and commits to nothing.
|
|
147
|
+
const badCompleteness = gate(BASE + NEGATIVE + '\n' + basis({ completeness: 'достаточная' }));
|
|
148
|
+
assert.equal(badCompleteness.code, 1, badCompleteness.raw);
|
|
149
|
+
assert.deepEqual(kinds(badCompleteness), ['NEGATIVE_COMPLETENESS_UNRECOGNISED']);
|
|
150
|
+
|
|
151
|
+
const badImplication = gate(BASE + NEGATIVE + '\n' + basis({ implication: 'скорее всего нет' }));
|
|
152
|
+
assert.equal(badImplication.code, 1, badImplication.raw);
|
|
153
|
+
assert.deepEqual(kinds(badImplication), ['NEGATIVE_IMPLICATION_UNRECOGNISED']);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('P6 - THE CEILING: a sample can never license «намеренно отсутствует»', {
|
|
157
|
+
skip: PY ? false : 'python3 is not available',
|
|
158
|
+
}, () => {
|
|
159
|
+
// The accepted correction, and the reason this record was worth shipping at all: an exhaustive
|
|
160
|
+
// LIST does not prove an exhaustive SEARCH, so absence in part of a corpus is absence from the
|
|
161
|
+
// SEARCH and not from the world.
|
|
162
|
+
for (const completeness of ['выборка — 34 из ~120 страниц', 'неизвестна']) {
|
|
163
|
+
const v = gate(BASE + NEGATIVE + '\n'
|
|
164
|
+
+ basis({ completeness, implication: 'намеренно отсутствует' }));
|
|
165
|
+
assert.equal(v.code, 1, completeness + ': ' + v.raw);
|
|
166
|
+
assert.deepEqual(kinds(v), ['NEGATIVE_CLAIM_EXCEEDS_ITS_CEILING'], completeness);
|
|
167
|
+
assert.match(v.json.findings[0].detail, /absence from the\s+SEARCH/);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// The weaker implication on the same sample is fine — the point is a CEILING, not a ban.
|
|
171
|
+
const ok = gate(BASE + NEGATIVE + '\n'
|
|
172
|
+
+ basis({ completeness: 'выборка — 34 из ~120 страниц', implication: 'не встретилось' }));
|
|
173
|
+
assert.equal(ok.code, 0, ok.raw);
|
|
174
|
+
|
|
175
|
+
// And the strong implication on an exhaustive corpus is exactly what the form is FOR.
|
|
176
|
+
const strong = gate(BASE + NEGATIVE + '\n' + basis({ implication: 'намеренно отсутствует' }));
|
|
177
|
+
assert.equal(strong.code, 0, strong.raw);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('P7 - detail after the closed word is allowed, not punished', {
|
|
181
|
+
skip: PY ? false : 'python3 is not available',
|
|
182
|
+
}, () => {
|
|
183
|
+
// «выборка — 34 из ~120 страниц» says how big the sample is, which is strictly better than the
|
|
184
|
+
// bare word. Refusing it would teach authors to write less.
|
|
185
|
+
const v = gate(BASE + NEGATIVE + '\n' + basis({
|
|
186
|
+
completeness: 'выборка, 34 из ~120 страниц; отказ выборки: раздел для партнёров под входом',
|
|
187
|
+
implication: 'не встретилось — ни на одной прочитанной странице',
|
|
188
|
+
}));
|
|
189
|
+
assert.equal(v.code, 0, v.raw);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('P8 - ordinary prose is NOT a negative universal claim', {
|
|
193
|
+
skip: PY ? false : 'python3 is not available',
|
|
194
|
+
}, () => {
|
|
195
|
+
// An eager guard is not a stricter guard; it is a guard people delete. `there is no` alone
|
|
196
|
+
// fires on ordinary sentences, so the patterns require the UNIVERSAL — which is precisely what
|
|
197
|
+
// makes the claim strong enough to need a basis.
|
|
198
|
+
const innocent = BASE
|
|
199
|
+
+ 'There is no cache in the current design, and no timeline yet.\n'
|
|
200
|
+
+ 'Нет данных за июль. Функция не описана в этом разделе.\n';
|
|
201
|
+
const v = gate(innocent);
|
|
202
|
+
assert.equal(v.code, 0, 'ordinary negation must not fire: ' + v.raw);
|
|
203
|
+
assert.deepEqual(kinds(v), []);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('P9 - one sentence produces ONE finding, however many patterns it matches', {
|
|
207
|
+
skip: PY ? false : 'python3 is not available',
|
|
208
|
+
}, () => {
|
|
209
|
+
// The measured sentence matches two patterns at once («ни у одного» and «ниша свободна»).
|
|
210
|
+
// Counting it twice would inflate exactly the number the twin test compares.
|
|
211
|
+
const v = gate(BASE + NEGATIVE);
|
|
212
|
+
assert.equal(v.json.findings.length, 1, JSON.stringify(kinds(v)));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('P10 - the basis in the appendix does not license a claim on page 2', {
|
|
216
|
+
skip: PY ? false : 'python3 is not available',
|
|
217
|
+
}, () => {
|
|
218
|
+
// A window, not a document: a block far away warns nobody reading the sentence.
|
|
219
|
+
const far = BASE + NEGATIVE + '\n' + 'x'.repeat(2000) + '\n' + basis();
|
|
220
|
+
const v = gate(far);
|
|
221
|
+
assert.equal(v.code, 1, v.raw);
|
|
222
|
+
assert.deepEqual(kinds(v), ['NEGATIVE_CONCLUSION_WITHOUT_BASIS']);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('P11 - the gate still refuses inputs it cannot read', {
|
|
226
|
+
skip: PY ? false : 'python3 is not available',
|
|
227
|
+
}, () => {
|
|
228
|
+
// The pre-existing contract must survive the addition: exit 2 for an unreadable input, never 0.
|
|
229
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'p-rep-neg2-')));
|
|
230
|
+
try {
|
|
231
|
+
const report = path.join(dir, 'report.md');
|
|
232
|
+
fs.writeFileSync(report, BASE + NEGATIVE);
|
|
233
|
+
const r = spawnSync(PY, [GATE, '--report', report, '--facts', path.join(dir, 'absent.json'),
|
|
234
|
+
'--json'], { encoding: 'utf8' });
|
|
235
|
+
assert.equal(r.status, 2, 'a gate that could not read its inputs has cleared nothing');
|
|
236
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('the doctrine is reachable, and the honest boundary is stated', () => {
|
|
241
|
+
test('P12 - the reference carries the form, the measurement and the ceiling', () => {
|
|
242
|
+
// It lives in references/ — loaded on demand — because the always-loaded corpus is spent. That
|
|
243
|
+
// placement is only legitimate if the SKILL points at it, which P13 asserts.
|
|
244
|
+
const ref = read(path.join('templates', '.claude', 'skills', 'goap-research-ed25519',
|
|
245
|
+
'references', 'negative-results.md'));
|
|
246
|
+
for (const field of ['КОРПУС', 'ПОЛНОТА', 'СПОСОБ ПОИСКА', 'ГРАНИЦА КОРПУСА', 'СЛЕДСТВИЕ']) {
|
|
247
|
+
assert.ok(ref.includes(field), 'the form must be spelled out: ' + field);
|
|
248
|
+
}
|
|
249
|
+
assert.match(ref, /ТВИН-ТЕСТОМ, а не грепом/, 'how the defect was established must survive');
|
|
250
|
+
assert.match(ref, /исчерпывающий ПЕРЕЧЕНЬ не доказывает\s*\n?исчерпывающего ПОИСКА/,
|
|
251
|
+
'the accepted correction is the load-bearing half and must not be lost');
|
|
252
|
+
assert.match(ref, /[Кк]арта\s+сайта/,
|
|
253
|
+
'the wrong-corpus case must stay: it is what the check deliberately cannot decide');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('P13 - the SKILL names the obligation and points at the long form', () => {
|
|
257
|
+
const skill = read(path.join('templates', '.claude', 'skills', 'goap-research-ed25519', 'SKILL.md'));
|
|
258
|
+
assert.match(skill, /A NEGATIVE conclusion .* MUST carry its own basis/,
|
|
259
|
+
'the duty must read as a duty in the file that is actually loaded');
|
|
260
|
+
assert.match(skill, /references\/negative-results\.md/,
|
|
261
|
+
'a doctrine nobody can reach is a doctrine nobody applies');
|
|
262
|
+
assert.match(skill, /property of the SEARCH/,
|
|
263
|
+
'the distinguishing sentence must be carried, not deferred');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('P14 - the gate PRINTS what it cannot decide', () => {
|
|
267
|
+
// Naming the corpus is enforceable; judging its fitness for the question is not. A gate that
|
|
268
|
+
// stayed silent about that would sell the stronger guarantee it does not have.
|
|
269
|
+
const src = read(path.join('templates', '.claude', 'skills', 'goap-research-ed25519',
|
|
270
|
+
'scripts', 'check_report_evidence.py'));
|
|
271
|
+
assert.match(src, /never that the named corpus fits the question/,
|
|
272
|
+
'the honest scope must be in the OUTPUT, not only in a comment');
|
|
273
|
+
assert.match(src, /def scan_negative_conclusions/, 'the second direction must exist as its own scan');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('P15 - the mutation registry carries this feature\'s guards', () => {
|
|
277
|
+
const registry = JSON.parse(read('test/mutation-registry.json'));
|
|
278
|
+
const gateFile = 'templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py';
|
|
279
|
+
const expected = {
|
|
280
|
+
'negative-conclusion-is-seen-at-all': gateFile,
|
|
281
|
+
'negative-ceiling-blocks-the-strong-claim': gateFile,
|
|
282
|
+
'negative-every-field-is-load-bearing': gateFile,
|
|
283
|
+
'negative-skill-carries-the-obligation':
|
|
284
|
+
'templates/.claude/skills/goap-research-ed25519/SKILL.md',
|
|
285
|
+
};
|
|
286
|
+
for (const [id, file] of Object.entries(expected)) {
|
|
287
|
+
const entry = registry.entries.find((e) => e.id === id);
|
|
288
|
+
assert.ok(entry, 'missing targeted mutation id ' + id);
|
|
289
|
+
assert.equal(entry.file, file, id + ' mutates the wrong surface');
|
|
290
|
+
assert.ok(entry.property && entry.property.length > 40,
|
|
291
|
+
id + ' needs a behavioral property, not a cosmetic label');
|
|
292
|
+
assert.ok(entry.mutation && entry.mutation.find && entry.mutation.replace,
|
|
293
|
+
id + ' needs a real source mutation');
|
|
294
|
+
assert.ok(Number.isInteger(entry.minFailing) && entry.minFailing >= 1,
|
|
295
|
+
id + ' must require at least one failing test');
|
|
296
|
+
assert.equal(read(entry.file).split(entry.mutation.find).length - 1, 1,
|
|
297
|
+
id + ': mutation anchor must occur exactly once');
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const assert = require("node:assert/strict");
|
|
2
|
+
const { mkdtempSync, readFileSync, rmSync } = require("node:fs");
|
|
3
|
+
const { tmpdir } = require("node:os");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const { spawnSync } = require("node:child_process");
|
|
6
|
+
const test = require("node:test");
|
|
7
|
+
|
|
8
|
+
const packageRoot = path.resolve(__dirname, "../..");
|
|
9
|
+
const scriptsDir = path.join(
|
|
10
|
+
packageRoot,
|
|
11
|
+
"templates/.claude/skills/goap-research-ed25519/scripts",
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
function runPython(source, args = []) {
|
|
15
|
+
const result = spawnSync("python3", ["-c", source, ...args], {
|
|
16
|
+
cwd: scriptsDir,
|
|
17
|
+
encoding: "utf8",
|
|
18
|
+
});
|
|
19
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
20
|
+
return result.stdout.trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test("refuses verbatim for excerpt not hash-bound to its FetchRecord", () => {
|
|
24
|
+
const verdict = runPython(`
|
|
25
|
+
import hashlib
|
|
26
|
+
import evidence_fetch as ef
|
|
27
|
+
import quote_provenance as qp
|
|
28
|
+
body = b"a phrase captured from source bytes"
|
|
29
|
+
record = ef.FetchRecord(url="https://example.test", final_url="https://example.test", status=200,
|
|
30
|
+
sha256_body=hashlib.sha256(body).hexdigest(), bytes_len=len(body),
|
|
31
|
+
fetched_at="2026-09-02T00:00:00Z", content_type="text/plain; charset=utf-8",
|
|
32
|
+
witness=ef._FETCH_WITNESS)
|
|
33
|
+
captured = qp.capture_excerpt(body, "phrase captured from source bytes", record)
|
|
34
|
+
captured["excerpt"] = "author-forged phrase captured from source bytes"
|
|
35
|
+
fact = {"acquisition": "raw-fetch", "source_url": record.final_url,
|
|
36
|
+
"sha256_body": record.sha256_body}
|
|
37
|
+
print(qp.verify_verbatim("phrase captured from source bytes", captured, fact))
|
|
38
|
+
`);
|
|
39
|
+
assert.equal(verdict, "hash-mismatch");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("verbatim policy accepts normalized exact match and rejects overlap paraphrase", () => {
|
|
43
|
+
const verdicts = runPython(`
|
|
44
|
+
import hashlib
|
|
45
|
+
import quote_provenance as qp
|
|
46
|
+
excerpt = 'The source says “iron\\u00a0status & recovery” — exactly.'
|
|
47
|
+
record = {"excerpt": excerpt, "content_type": "text/html; charset=utf-8",
|
|
48
|
+
"source_url": "https://example.test", "sha256_body": "a" * 64,
|
|
49
|
+
"sha256_excerpt": hashlib.sha256(excerpt.encode()).hexdigest()}
|
|
50
|
+
fact = {"acquisition": "raw-fetch", "source_url": "https://example.test", "sha256_body": "a" * 64}
|
|
51
|
+
print(qp.verify_verbatim('iron status & recovery" - exactly', record, fact))
|
|
52
|
+
print(qp.verify_verbatim("iron status recovery was described exactly", record, fact))
|
|
53
|
+
`);
|
|
54
|
+
assert.deepEqual(verdicts.split("\n"), ["verbatim-confirmed", "not-in-excerpt"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("non-text body triggers named verbatim refusal", () => {
|
|
58
|
+
const refusal = runPython(`
|
|
59
|
+
import hashlib
|
|
60
|
+
import evidence_fetch as ef
|
|
61
|
+
import quote_provenance as qp
|
|
62
|
+
body = b"%PDF-1.7 quoted phrase in a binary body"
|
|
63
|
+
record = ef.FetchRecord(url="https://example.test/file.pdf", final_url="https://example.test/file.pdf",
|
|
64
|
+
status=200, sha256_body=hashlib.sha256(body).hexdigest(), bytes_len=len(body),
|
|
65
|
+
fetched_at="2026-09-02T00:00:00Z", content_type="application/pdf", witness=ef._FETCH_WITNESS)
|
|
66
|
+
try:
|
|
67
|
+
qp.capture_excerpt(body, "quoted phrase in a binary body", record)
|
|
68
|
+
except qp.ExcerptCaptureRefused as exc:
|
|
69
|
+
print(str(exc))
|
|
70
|
+
else:
|
|
71
|
+
raise AssertionError("PDF body unexpectedly produced an excerpt")
|
|
72
|
+
`);
|
|
73
|
+
assert.match(refusal, /non-text body/);
|
|
74
|
+
assert.match(refusal, /application\/pdf/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("capture run writes gitignore that excludes excerpts", () => {
|
|
78
|
+
const temporary = mkdtempSync(path.join(tmpdir(), "quote-provenance-"));
|
|
79
|
+
try {
|
|
80
|
+
const init = spawnSync("git", ["init", "--quiet", temporary], { encoding: "utf8" });
|
|
81
|
+
assert.equal(init.status, 0, init.stderr);
|
|
82
|
+
const store = path.join(temporary, "evidence_excerpts");
|
|
83
|
+
const excerptId = runPython(`
|
|
84
|
+
import hashlib
|
|
85
|
+
import sys
|
|
86
|
+
import evidence_fetch as ef
|
|
87
|
+
import quote_provenance as qp
|
|
88
|
+
body = b"captured phrase from a fetched source"
|
|
89
|
+
record = ef.FetchRecord(url="https://example.test", final_url="https://example.test", status=200,
|
|
90
|
+
sha256_body=hashlib.sha256(body).hexdigest(), bytes_len=len(body),
|
|
91
|
+
fetched_at="2026-09-02T00:00:00Z", content_type="text/plain; charset=utf-8",
|
|
92
|
+
witness=ef._FETCH_WITNESS)
|
|
93
|
+
captured = qp.capture_excerpt(body, "captured phrase from a fetched source", record)
|
|
94
|
+
qp.write_excerpt(sys.argv[1], captured)
|
|
95
|
+
print(captured["excerpt_id"])
|
|
96
|
+
`, [store]);
|
|
97
|
+
assert.equal(readFileSync(path.join(store, ".gitignore"), "utf8"), "*\n");
|
|
98
|
+
const ignored = spawnSync(
|
|
99
|
+
"git",
|
|
100
|
+
["-C", temporary, "check-ignore", "--quiet", path.join("evidence_excerpts", excerptId)],
|
|
101
|
+
{ encoding: "utf8" },
|
|
102
|
+
);
|
|
103
|
+
assert.equal(ignored.status, 0, ignored.stderr || "captured excerpt was not ignored");
|
|
104
|
+
} finally {
|
|
105
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("unknown or absent acquisition method fails closed", () => {
|
|
110
|
+
const states = runPython(`
|
|
111
|
+
import hashlib
|
|
112
|
+
import quote_provenance as qp
|
|
113
|
+
text = "a captured phrase long enough to be checked"
|
|
114
|
+
record = {"excerpt": text, "content_type": "text/plain; charset=utf-8",
|
|
115
|
+
"source_url": "https://example.test", "sha256_body": "b" * 64,
|
|
116
|
+
"sha256_excerpt": hashlib.sha256(text.encode()).hexdigest()}
|
|
117
|
+
for method in (None, "Raw-Fetch", "webfetch"):
|
|
118
|
+
fact = {"acquisition": method, "source_url": "https://example.test", "sha256_body": "b" * 64}
|
|
119
|
+
print(qp.verify_verbatim(text, record, fact))
|
|
120
|
+
`);
|
|
121
|
+
assert.deepEqual(states.split("\n"), ["method-unknown", "method-unknown", "method-unknown"]);
|
|
122
|
+
});
|