@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,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
|
+
});
|
|
@@ -115,12 +115,14 @@ const count = (out, key) => {
|
|
|
115
115
|
}
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
-
test('P4 - files[]
|
|
118
|
+
test('P4 - files[] ships only the deliberate checker, not the scripts directory', () => {
|
|
119
119
|
// The rejected alternative. Adding scripts/ fixes ONE of the two files and hands users build
|
|
120
120
|
// machinery for no reason; module-copy-identity needs sibling PACKAGES, which no tarball has.
|
|
121
121
|
const pkg = JSON.parse(fs.readFileSync(path.join(PKG, 'package.json'), 'utf-8'));
|
|
122
122
|
assert.ok(!(pkg.files || []).includes('scripts/'),
|
|
123
123
|
'shipping scripts/ was rejected: it fixes one file of two and ships build machinery');
|
|
124
|
+
assert.ok((pkg.files || []).includes('scripts/check-pipeline-gaps.sh'),
|
|
125
|
+
'the consumer-facing checker must be named explicitly in files[]');
|
|
124
126
|
assert.ok((pkg.files || []).includes('tests/'),
|
|
125
127
|
'this whole file only matters because tests/ ships — if that changes, revisit');
|
|
126
128
|
});
|