@dzhechkov/harness-core 0.8.38 → 0.8.39
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 +223 -123
- package/README.md +97 -2
- package/dist/agentdb-index.d.ts +3 -3
- package/dist/agentdb-index.js +5 -5
- package/dist/agentdb-index.js.map +1 -1
- package/dist/amendment-trace.d.ts.map +1 -1
- package/dist/amendment-trace.js +4 -1
- package/dist/amendment-trace.js.map +1 -1
- package/dist/backlog.d.ts +2 -1
- package/dist/backlog.d.ts.map +1 -1
- package/dist/backlog.js +3 -2
- package/dist/backlog.js.map +1 -1
- package/dist/brain.d.ts.map +1 -1
- package/dist/brain.js +9 -2
- package/dist/brain.js.map +1 -1
- package/dist/bto-optimize.d.ts.map +1 -1
- package/dist/bto-optimize.js +9 -12
- package/dist/bto-optimize.js.map +1 -1
- package/dist/claim-check.d.ts.map +1 -1
- package/dist/claim-check.js +50 -14
- package/dist/claim-check.js.map +1 -1
- package/dist/experiment-assign.d.ts +110 -0
- package/dist/experiment-assign.d.ts.map +1 -0
- package/dist/experiment-assign.js +229 -0
- package/dist/experiment-assign.js.map +1 -0
- package/dist/feature-adr-checkpoints.d.ts +7 -2
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +20 -5
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-envelope.d.ts +12 -0
- package/dist/feature-adr-envelope.d.ts.map +1 -1
- package/dist/feature-adr-envelope.js +12 -1
- package/dist/feature-adr-envelope.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +4 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +4 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/index.js.map +1 -1
- package/dist/ledger-cost-fill.d.ts +58 -0
- package/dist/ledger-cost-fill.d.ts.map +1 -0
- package/dist/ledger-cost-fill.js +78 -0
- package/dist/ledger-cost-fill.js.map +1 -0
- package/dist/loop-blobs.generated.js +2 -2
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +29 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +20 -0
- package/dist/mutation-gate.js.map +1 -1
- package/dist/name-check.d.ts +20 -1
- package/dist/name-check.d.ts.map +1 -1
- package/dist/name-check.js +42 -1
- package/dist/name-check.js.map +1 -1
- package/dist/no-stubs.d.ts +10 -0
- package/dist/no-stubs.d.ts.map +1 -1
- package/dist/no-stubs.js +13 -9
- package/dist/no-stubs.js.map +1 -1
- package/dist/patterns.d.ts +24 -0
- package/dist/patterns.d.ts.map +1 -1
- package/dist/patterns.js +49 -9
- package/dist/patterns.js.map +1 -1
- package/dist/publish-source-scope.d.ts +32 -0
- package/dist/publish-source-scope.d.ts.map +1 -0
- package/dist/publish-source-scope.js +53 -0
- package/dist/publish-source-scope.js.map +1 -0
- package/dist/publish.d.ts +5 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +32 -21
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +4 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +21 -11
- package/dist/qe-bridge.js.map +1 -1
- package/dist/rake-analyzer.d.ts +10 -3
- package/dist/rake-analyzer.d.ts.map +1 -1
- package/dist/rake-analyzer.js +84 -22
- package/dist/rake-analyzer.js.map +1 -1
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +8 -4
- package/dist/recap.js.map +1 -1
- package/dist/release.d.ts +2 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +18 -4
- package/dist/release.js.map +1 -1
- package/dist/reqe.d.ts.map +1 -1
- package/dist/reqe.js +11 -1
- package/dist/reqe.js.map +1 -1
- package/dist/run-records.d.ts +18 -4
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +61 -7
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +8 -1
- package/dist/score.js.map +1 -1
- package/dist/sign.d.ts +23 -0
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +52 -0
- package/dist/sign.js.map +1 -1
- package/dist/skills-verify.d.ts +8 -5
- package/dist/skills-verify.d.ts.map +1 -1
- package/dist/skills-verify.js +59 -7
- package/dist/skills-verify.js.map +1 -1
- package/dist/store-guard-prune.d.ts +22 -0
- package/dist/store-guard-prune.d.ts.map +1 -0
- package/dist/store-guard-prune.js +54 -0
- package/dist/store-guard-prune.js.map +1 -0
- package/dist/sweep-failure-classify.d.ts +45 -0
- package/dist/sweep-failure-classify.d.ts.map +1 -0
- package/dist/sweep-failure-classify.js +90 -0
- package/dist/sweep-failure-classify.js.map +1 -0
- package/dist/workflow-run-dispatch.d.ts +14 -2
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +14 -2
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +372 -122
- package/src/agentdb-index.ts +5 -5
- package/src/amendment-trace.ts +4 -1
- package/src/backlog.ts +3 -2
- package/src/brain.ts +8 -2
- package/src/bto-optimize.ts +10 -8
- package/src/claim-check.ts +48 -12
- package/src/experiment-assign.ts +276 -0
- package/src/feature-adr-checkpoints.ts +20 -5
- package/src/feature-adr-envelope.ts +24 -1
- package/src/feature-adr-routing.ts +4 -0
- package/src/index.ts +17 -3
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +59 -0
- package/src/name-check.ts +43 -1
- package/src/no-stubs.ts +14 -5
- package/src/patterns.ts +49 -8
- package/src/publish-source-scope.ts +53 -0
- package/src/publish.ts +38 -16
- package/src/qe-bridge.ts +22 -11
- package/src/rake-analyzer.ts +75 -20
- package/src/rake-signatures.json +98 -0
- package/src/recap.ts +8 -4
- package/src/release.ts +18 -4
- package/src/reqe.ts +11 -1
- package/src/run-records.ts +78 -10
- package/src/score.ts +8 -1
- package/src/sign.ts +53 -0
- package/src/skills-verify.ts +69 -9
- package/src/store-guard-prune.ts +81 -0
- package/src/sweep-failure-classify.ts +88 -0
- package/src/workflow-run-dispatch.ts +14 -2
package/src/rake-analyzer.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
18
|
+
import { maskMarkdown } from './markdown-masker.js';
|
|
18
19
|
import { join } from 'node:path';
|
|
19
20
|
|
|
20
21
|
export type Severity = 'blocker' | 'high' | 'medium' | 'low' | 'unknown';
|
|
@@ -47,28 +48,67 @@ export interface RakeReport {
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export interface RakeSignature { readonly id: string; readonly label: string; readonly patterns: readonly RegExp[] }
|
|
51
|
+
export interface RakeSignatureSpec { readonly id: string; readonly label: string; readonly patterns: readonly string[]; readonly flags?: string }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decode the data-only signature table. This fails closed: stateful `g`/`y` patterns would make
|
|
55
|
+
* RegExp.test() depend on lastIndex, which breaks signatureOf's determinism.
|
|
56
|
+
*/
|
|
57
|
+
export function loadRakeSignatures(raw: unknown): readonly RakeSignature[] {
|
|
58
|
+
if (!Array.isArray(raw)) throw new Error('Rake signatures: expected an array');
|
|
59
|
+
const signatures = raw.map((entry, index): RakeSignature => {
|
|
60
|
+
const name = typeof entry === 'object' && entry !== null && typeof (entry as { id?: unknown }).id === 'string'
|
|
61
|
+
? (entry as { id: string }).id
|
|
62
|
+
: `entry #${index}`;
|
|
63
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw new Error(`Rake signature ${name}: expected an object`);
|
|
64
|
+
const spec = entry as Partial<RakeSignatureSpec>;
|
|
65
|
+
if (typeof spec.id !== 'string' || spec.id === '') throw new Error(`Rake signature ${name}: missing id`);
|
|
66
|
+
if (typeof spec.label !== 'string' || spec.label === '') throw new Error(`Rake signature ${name}: missing label`);
|
|
67
|
+
if (!Array.isArray(spec.patterns) || spec.patterns.length === 0 || spec.patterns.some((p) => typeof p !== 'string' || p === '')) {
|
|
68
|
+
throw new Error(`Rake signature ${name}: patterns must be a non-empty string array`);
|
|
69
|
+
}
|
|
70
|
+
const flags = spec.flags ?? '';
|
|
71
|
+
if (typeof flags !== 'string') throw new Error(`Rake signature ${name}: flags must be a string`);
|
|
72
|
+
if (/[gy]/.test(flags)) throw new Error(`Rake signature ${name}: stateful g/y flags are forbidden`);
|
|
73
|
+
let patterns: RegExp[];
|
|
74
|
+
try { patterns = spec.patterns.map((pattern) => new RegExp(pattern, flags)); }
|
|
75
|
+
catch (error) { throw new Error(`Rake signature ${name}: invalid regular expression (${error instanceof Error ? error.message : String(error)})`); }
|
|
76
|
+
for (const pattern of patterns) Object.freeze(pattern);
|
|
77
|
+
return Object.freeze({ id: spec.id, label: spec.label, patterns: Object.freeze(patterns) });
|
|
78
|
+
});
|
|
79
|
+
return Object.freeze(signatures);
|
|
80
|
+
}
|
|
50
81
|
|
|
51
82
|
/**
|
|
52
83
|
* Known rake classes (extensible, data-only). Seeded from the classes that actually recur in this repo's
|
|
53
84
|
* QE reports — that IS the dogfood. First match in order wins; unmatched → normalized-text bucket.
|
|
54
85
|
*/
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Таблица живёт В ПАКЕТЕ, а не в `docs/`, и путь записан ровно как у близнеца
|
|
88
|
+
* `slop-lint.ts` → `../src/slop-markers.json`: из собранного `dist/` он указывает в `src/`,
|
|
89
|
+
* который входит в `files[]` и потому ПУБЛИКУЕТСЯ. Запись бэклога предлагала
|
|
90
|
+
* `docs/methodology/`, но чтение стоит на верхнем уровне модуля — в опубликованном пакете
|
|
91
|
+
* такого пути нет, и модуль упал бы ПРИ ИМПОРТЕ, утащив за собой каждого потребителя.
|
|
92
|
+
* Человекочитаемое описание схемы осталось в `docs/methodology/rake-signatures.md`.
|
|
93
|
+
*/
|
|
94
|
+
const RAKE_SIGNATURES_FILE = new URL('../src/rake-signatures.json', import.meta.url);
|
|
95
|
+
export const RAKE_SIGNATURES = loadRakeSignatures(readRakeSignaturesFile(RAKE_SIGNATURES_FILE));
|
|
96
|
+
|
|
97
|
+
/** Отдельный читатель, чтобы отсутствие или порча файла назывались СВОИМИ ИМЕНАМИ, а не
|
|
98
|
+
* прилетали сырым ENOENT/SyntaxError из середины загрузки модуля. */
|
|
99
|
+
function readRakeSignaturesFile(url: URL): unknown {
|
|
100
|
+
let raw: string;
|
|
101
|
+
try {
|
|
102
|
+
raw = readFileSync(url, 'utf8');
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new Error(`rake-signatures: таблица не найдена по пути ${url.pathname} — пакет собран без src/? (${err instanceof Error ? err.message : String(err)})`);
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
throw new Error(`rake-signatures: ${url.pathname} не разбирается как JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
72
112
|
|
|
73
113
|
const byStr = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
74
114
|
const uniqSorted = (xs: readonly string[]): string[] => [...new Set(xs)].sort(byStr);
|
|
@@ -97,8 +137,8 @@ const toSeverity = (raw: string): Severity => SEV_MAP[raw.trim().toLowerCase()]
|
|
|
97
137
|
const SITE_RE = /([\w./-]+\.(?:ts|js|tsx|jsx|py|go|md|json|yml|yaml):\d+)/;
|
|
98
138
|
|
|
99
139
|
/** The signature of a finding: first matching rule, else the normalized-text bucket. */
|
|
100
|
-
export function signatureOf(finding: Finding): { id: string; label: string } {
|
|
101
|
-
for (const s of
|
|
140
|
+
export function signatureOf(finding: Finding, signatures: readonly RakeSignature[] = RAKE_SIGNATURES): { id: string; label: string } {
|
|
141
|
+
for (const s of signatures) {
|
|
102
142
|
if (s.patterns.some((p) => p.test(finding.text))) return { id: s.id, label: s.label };
|
|
103
143
|
}
|
|
104
144
|
const key = normalizeText(finding.text);
|
|
@@ -115,13 +155,28 @@ export function signatureOf(finding: Finding): { id: string; label: string } {
|
|
|
115
155
|
*/
|
|
116
156
|
export function extractFindings(markdown: string, source: string): Finding[] {
|
|
117
157
|
const out: Finding[] = [];
|
|
158
|
+
// Строки ВНУТРИ блока кода находками не являются: отчёт QE регулярно показывает ПРИМЕР того,
|
|
159
|
+
// как писать не надо, и пример оформлен той же таблицей. ИЗМЕРЕНО 2026-09-19: отчёт с одной
|
|
160
|
+
// настоящей находкой и одной строкой-примером в ```-блоке давал ДВЕ находки.
|
|
161
|
+
// Маскировщик здесь ОБЩИЙ (`markdown-masker`), тот же, что у `amendment-trace` и у гейта K2 —
|
|
162
|
+
// запись 8b309a04 просила один проход на весь харнесс, а не третий собственный разбор.
|
|
163
|
+
// Решение «считать ли строку» принимается по МАСКЕ, а текст находки берётся из ИСХОДНИКА:
|
|
164
|
+
// маска сохраняет разбиение на строки, но не содержимое.
|
|
165
|
+
// ОДИН механизм: читаем МАСКУ. Маскировщик заменяет строки внутри блока кода филлером той же
|
|
166
|
+
// длины и не трогает остальные, поэтому отдельный список «пропустить замаскированные» был бы
|
|
167
|
+
// ВТОРОЙ защитой — и мутация, снимающая только её, осталась бы эквивалентной (проверено:
|
|
168
|
+
// 35 тестов из 35 зелёные при «снятой» починке). Ровно тот же капкан, что сегодня в гейте K2.
|
|
169
|
+
const maskLines = String(maskMarkdown(markdown)).split('\n');
|
|
118
170
|
const push = (severity: Severity, text: string): void => {
|
|
119
171
|
const t = text.replace(/\s+/g, ' ').trim();
|
|
120
172
|
if (t.length < 8) return; // too short to be a finding
|
|
121
173
|
const site = SITE_RE.exec(t)?.[1];
|
|
122
174
|
out.push(site ? { source, severity, text: t, site } : { source, severity, text: t });
|
|
123
175
|
};
|
|
124
|
-
|
|
176
|
+
const sourceLines = markdown.split('\n');
|
|
177
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
178
|
+
// Читаем маску, а не исходник: строка, спрятанная блоком кода, до правил ниже не доходит.
|
|
179
|
+
const line = maskLines[i] ?? sourceLines[i] ?? '';
|
|
125
180
|
// (a) table row: | ... | <sev> | <finding> | ...
|
|
126
181
|
const cells = line.includes('|') ? line.split('|').map((c) => c.trim()) : null;
|
|
127
182
|
if (cells && cells.length >= 4) {
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "esm-require-footgun",
|
|
4
|
+
"label": "ESM lazy require() undefined at runtime",
|
|
5
|
+
"patterns": ["require\\(['\\\"]node:", "\\besm\\b.*require", "lazy require"],
|
|
6
|
+
"flags": "i"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"id": "untested-adr-property",
|
|
10
|
+
"label": "ADR-named safety property left untested",
|
|
11
|
+
"patterns": ["load-bearing.*(untested|not\\s+tested|no\\s+test)", "adr.*names.*(property|test)", "safety property.*test"],
|
|
12
|
+
"flags": "i"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"id": "claim-check-fp",
|
|
16
|
+
"label": "claim-check false positive / untagged count",
|
|
17
|
+
"patterns": ["claim-check.*(false positive|\\bfp\\b)", "untagged.*(count|claim)", "metric term"],
|
|
18
|
+
"flags": "i"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "path-traversal",
|
|
22
|
+
"label": "path not constrained to the repo (traversal)",
|
|
23
|
+
"patterns": ["\\.\\./", "(?=.*travers)(?=.*(repo|root|\\bpath\\b|director|/etc/))", "escapes.{0,12}repo"],
|
|
24
|
+
"flags": "i"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "silent-drop-or-inject",
|
|
28
|
+
"label": "silent drop / silent injection (no report)",
|
|
29
|
+
"patterns": ["silent(ly)?\\s+(drop|inject|discard|dropped)", "no silent (injection|caps|drop)"],
|
|
30
|
+
"flags": "i"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "swallow-generic-exception",
|
|
34
|
+
"label": "generic except/catch swallows real bugs",
|
|
35
|
+
"patterns": ["except\\s+Exception", "catch.*swallow", "generic (exception|catch)"],
|
|
36
|
+
"flags": "i"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "determinism-hole",
|
|
40
|
+
"label": "non-deterministic output (unsorted/clock/random)",
|
|
41
|
+
"patterns": ["non-determinis", "determinism hole", "unsorted|not sorted"],
|
|
42
|
+
"flags": "i"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "cross-model-self-qe",
|
|
46
|
+
"label": "coder self-QE instead of cross-model",
|
|
47
|
+
"patterns": ["self-qe", "coder.*(review|qe).*(itself|self)", "cross-model", "same-family.{0,40}(qe|review)", "cross-family.{0,60}(not obtained|did not happen|fell back)"],
|
|
48
|
+
"flags": "i"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "malformed-input-bypass",
|
|
52
|
+
"label": "malformed input bypasses validation",
|
|
53
|
+
"patterns": ["array.*(pass|bypass)", "malformed.*(bypass|pass|manifest)", "typeof.*object"],
|
|
54
|
+
"flags": "i"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "wiring-tail",
|
|
58
|
+
"label": "implementation wiring tail left unfinished",
|
|
59
|
+
"patterns": ["wiring[-\\s]tail", "wiring-хвост", "незавершённая проводка"],
|
|
60
|
+
"flags": "i"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "canonical-copy-edit",
|
|
64
|
+
"label": "edit made in a canonical copy",
|
|
65
|
+
"patterns": ["canonical\\s+copy", "каноническ\\w*\\s+коп", "edited in the\\s+\\*\\*canonical\\*\\*\\s+copy"],
|
|
66
|
+
"flags": "i"
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"id": "zero-or-partial-landing",
|
|
70
|
+
"label": "zero or partial implementation landing",
|
|
71
|
+
"patterns": ["zero\\s+code\\s+landed", "genuinely-not-landed", "step\\s*7.{0,50}did not land"],
|
|
72
|
+
"flags": "i"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "corpus-preimage-not-repinned",
|
|
76
|
+
"label": "corpus preimage was not re-pinned",
|
|
77
|
+
"patterns": ["preimage.{0,80}(never|not).{0,30}re-?pinned", "(?:registry|corpus).{0,40}(?:find|anchor).{0,80}(never|not).{0,30}re-?pinned", "find.{0,10}string was never re-pinned"],
|
|
78
|
+
"flags": "i"
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"id": "verdict-from-proxy",
|
|
82
|
+
"label": "verdict inferred from a proxy instead of the subject",
|
|
83
|
+
"patterns": ["(?:verdict|decision|grade).{0,60}proxy", "proxy.{0,60}(?:verdict|decision|grade|property)", "asserted a\\s+\\*\\*?proxy\\*\\*?.{0,80}not the\\s+\\*\\*?property"],
|
|
84
|
+
"flags": "i"
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
"id": "single-language-book-silence",
|
|
88
|
+
"label": "book result silently omitted for a single-language query",
|
|
89
|
+
"patterns": ["books are silent", "книги молчат", "book.{0,50}(silent|silence)"],
|
|
90
|
+
"flags": "i"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "reserved-heading-collision",
|
|
94
|
+
"label": "reserved heading collision",
|
|
95
|
+
"patterns": ["(?:name|heading).{0,12}collision.{0,60}reused.{0,8}#{1,6}\\s", "reused.{0,8}#{1,6}\\s+scenario coverage", "заголов.{0,50}(?:collision|столкнов)"],
|
|
96
|
+
"flags": "i"
|
|
97
|
+
}
|
|
98
|
+
]
|
package/src/recap.ts
CHANGED
|
@@ -351,16 +351,20 @@ export function buildRecap(facts: RecapFacts): RecapReport {
|
|
|
351
351
|
const covered = coveredWindow(v, facts.window);
|
|
352
352
|
const items = (facts.deliveries?.items ?? []).map(normaliseDelivery).filter((d) => withinWindow(covered, d.createdIso));
|
|
353
353
|
const graded = items.filter((d): d is Extract<Delivery, { gradeStatus: 'unique' }> => d.gradeStatus === 'unique');
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
354
|
+
// recap-invalid-bucket (backlog 3bd73eec): the two were printed as ONE number, so a reader could
|
|
355
|
+
// not tell "the report states two grades" from "the report's verdict line is malformed" — and the
|
|
356
|
+
// second is a FIXABLE authoring defect in our own artifact, while the first is a real
|
|
357
|
+
// disagreement between reviews. `dz score --slug` already separated them; recap now does too.
|
|
358
|
+
const ambiguous = items.filter((d) => d.gradeStatus === 'ambiguous');
|
|
359
|
+
const invalid = items.filter((d) => d.gradeStatus === 'invalid');
|
|
357
360
|
const ungraded = items.filter((d) => d.gradeStatus === 'none' || d.gradeStatus === 'no-report');
|
|
358
361
|
const lines = sectionLines(v, facts.window, (scope) => items.length === 0
|
|
359
362
|
? emptyOrUnavailable(v, `no feature directories were created in ${scope}`)
|
|
360
363
|
: [
|
|
361
364
|
`${items.length} feature director${items.length === 1 ? 'y' : 'ies'} created in ${scope}`,
|
|
362
365
|
`${graded.length} carry a grade an independent review stated unambiguously${graded.length > 0 ? `: ${tally(graded.map((d) => d.grade))}` : ''}`,
|
|
363
|
-
`${ambiguous.length} have a report that states MORE THAN ONE grade
|
|
366
|
+
`${ambiguous.length} have a report that states MORE THAN ONE grade — reported as ambiguous, never guessed`,
|
|
367
|
+
`${invalid.length} have a findings table but a MALFORMED or missing QE-VERDICT line — the report cannot be read, which is a formatting problem rather than two reviews disagreeing`,
|
|
364
368
|
`${ungraded.length} have no letter grade in their report, or no report at all`,
|
|
365
369
|
'cadence is not value: this counts deliveries, not what they were worth',
|
|
366
370
|
]);
|
package/src/release.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
25
25
|
import type { Dirent } from 'node:fs';
|
|
26
26
|
import { join, relative } from 'node:path';
|
|
27
27
|
|
|
28
|
-
import { discoverPackages, orderByDependencies } from './publish.js';
|
|
28
|
+
import { discoverPackages, matchesPublishFilter, orderByDependencies } from './publish.js';
|
|
29
29
|
import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
|
|
30
30
|
import type { PackedInstallPlan, PackedInstallExecution } from './packed-install-smoke.js';
|
|
31
31
|
|
|
@@ -243,9 +243,14 @@ export function collectPackageFacts(monorepoRoot: string, filter?: readonly stri
|
|
|
243
243
|
if (filter !== undefined && filter.length === 0) {
|
|
244
244
|
throw new Error('release: --filter requires a non-empty list of package-name substrings (empty would match ALL packages)');
|
|
245
245
|
}
|
|
246
|
+
// The twin of the publish guard: an empty ELEMENT matches every name, so the list being
|
|
247
|
+
// non-empty is not enough. Both doors must be shut or the weaker one becomes the entrance.
|
|
248
|
+
if (filter?.some((f) => f.length === 0)) {
|
|
249
|
+
throw new Error('release: --filter requires non-empty package-name substrings (empty would match ALL packages)');
|
|
250
|
+
}
|
|
246
251
|
const discovered = discoverPackages(monorepoRoot);
|
|
247
252
|
const selected =
|
|
248
|
-
filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => p
|
|
253
|
+
filter === undefined ? discovered : discovered.filter((p) => filter.some((f) => matchesPublishFilter(p, f, monorepoRoot)));
|
|
249
254
|
const ordered = orderByDependencies(selected);
|
|
250
255
|
|
|
251
256
|
return ordered.map((p) => {
|
|
@@ -727,7 +732,8 @@ export function outputTail(stdout: unknown, stderr: unknown, maxLines = 40, maxB
|
|
|
727
732
|
* - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
|
|
728
733
|
* KEY survives, only the value is replaced;
|
|
729
734
|
* - `Bearer <token>` HTTP auth headers;
|
|
730
|
-
* - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk
|
|
735
|
+
* - vendor-prefixed tokens: `npm_…`, `ghp_…`, `github_pat_…`, `sk-…` (hyphenated forms whole), `AKIA…`;
|
|
736
|
+
* - JSON keys (`"token":"…"`) and env-style names ending in TOKEN/SECRET/PASSWORD/API_KEY;
|
|
731
737
|
* - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
|
|
732
738
|
* without a recognisable prefix.
|
|
733
739
|
* Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
|
|
@@ -738,8 +744,16 @@ export function redactSecrets(text: string): string {
|
|
|
738
744
|
out = out.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]');
|
|
739
745
|
out = out.replace(/\bnpm_[A-Za-z0-9]+/g, '[redacted]');
|
|
740
746
|
out = out.replace(/\bghp_[A-Za-z0-9]+/g, '[redacted]');
|
|
741
|
-
out = out.replace(/\
|
|
747
|
+
out = out.replace(/\bgithub_pat_[A-Za-z0-9_]+/g, '[redacted]');
|
|
748
|
+
// `sk-proj-…` (and any other hyphenated vendor form) is redacted WHOLE — the old `[A-Za-z0-9]+` stopped
|
|
749
|
+
// at the first hyphen and let the tail through (MEASURED 2026-09-13, backlog 2df653d9).
|
|
750
|
+
out = out.replace(/\bsk-[A-Za-z0-9_-]+/g, '[redacted]');
|
|
742
751
|
out = out.replace(/\bAKIA[A-Za-z0-9]+/g, '[redacted]');
|
|
752
|
+
// JSON keys: `"token":"…"` has a quote between key and colon, so the labelled rule below never saw it.
|
|
753
|
+
out = out.replace(/"(token|secret|password|api[_-]?key|access[_-]?token)"\s*:\s*"[^"]*"/gi, '"$1":"[redacted]"');
|
|
754
|
+
// Env-style names that END in a secret word (`GITHUB_TOKEN=`, `NPM_ACCESS_TOKEN=`): `_` is a word
|
|
755
|
+
// character, so `\btoken` never matched them.
|
|
756
|
+
out = out.replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY))(\s*[:=]\s*)(\S+)/g, '$1$2[redacted]');
|
|
743
757
|
out = out.replace(/\b(token|secret|password)(\s*[:=]\s*)(\S+)/gi, '$1$2[redacted]');
|
|
744
758
|
out = out.replace(/\b[A-Za-z0-9+/=]{32,}\b/g, '[redacted]');
|
|
745
759
|
return out;
|
package/src/reqe.ts
CHANGED
|
@@ -187,7 +187,17 @@ export interface ReqeSettlement {
|
|
|
187
187
|
export function extractReportGrade(text: string): string | null {
|
|
188
188
|
// the lookahead rejects RANGES in punctuation form (A-F, A/F) AND word form (A through F,
|
|
189
189
|
// A to F) — Codex QE round-2 #6: 'GRADE A through F' must not read as grade A
|
|
190
|
-
|
|
190
|
+
// bridge-grade-grammar (backlog ddf83072): the rest of the instrument already speaks in modifiers —
|
|
191
|
+
// `dz round close --grade B-` is accepted and `dz recap` tallies `A-×21` — so a review that writes
|
|
192
|
+
// `GRADE B-` must be readable here too. The modifier is allowed ONLY when a letter does not follow
|
|
193
|
+
// it, because `GRADE A-F` is a RANGE (the boilerplate "GRADE A-F + findings"), not an A-minus.
|
|
194
|
+
// The dash class below is the UNICODE dash family, not just the ASCII hyphen (Codex review HIGH #2):
|
|
195
|
+
// a reviewer's editor turns `A-F` into `A−F` (U+2212) or `A‐F` (U+2010) without asking, and a range
|
|
196
|
+
// written that way used to read as a bare `A`. Any dash-family character following the letter means
|
|
197
|
+
// the text is not a plain grade, so the extractor refuses instead of guessing which half was meant —
|
|
198
|
+
// the same rule that makes `GRADE B-Critical` null. A modifier is only ever its ASCII form (`+`, `-`).
|
|
199
|
+
// hyphen-minus - | hyphen ‐ | non-breaking ‑ | figure ‒ | en – | em — | horizontal bar ― | minus −
|
|
200
|
+
const matches = [...String(text ?? '').matchAll(/^\s*(?:\*{0,2}#{0,4}\s*)?GRADE\s*[:=‐-―−-]?\s*([A-F](?:[+-](?![ \t]*[A-F]\b))?)(?![A-Za-z])(?![ \t]*[-‐-―−])(?!\s*(?:through|to|thru)\s*[A-F]\b)/gim)];
|
|
191
201
|
const distinct = new Set(matches.map((m) => (m[1] ?? '').toUpperCase()).filter((g) => g !== ''));
|
|
192
202
|
if (distinct.size !== 1) return null;
|
|
193
203
|
return [...distinct][0] ?? null;
|
package/src/run-records.ts
CHANGED
|
@@ -141,6 +141,17 @@ function priceLookup(modelId: string, table: Readonly<Record<string, LedgerPrice
|
|
|
141
141
|
return best;
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
// instrument-round-b fix-round-1 (Codex r1 HIGH finding 3, ADR-001 D4 amended): `--no-strict` was a
|
|
145
|
+
// BLANKET opt-out — since the one demonstrated automatic writer (the pipeline) always passed it, the
|
|
146
|
+
// new strict-by-default was operationally empty for every real caller. Replaced by a NAMED, SCOPED
|
|
147
|
+
// allowance: the caller states exactly which fields it expects to be incomplete
|
|
148
|
+
// (`allowIncomplete`) and WHY, from a closed set of reason codes — an incomplete row is written only
|
|
149
|
+
// when its actual incompleteness is a SUBSET of what was named AND the reason is recognized.
|
|
150
|
+
// Growing the incompleteness beyond what was declared (e.g. `envelope` going missing tomorrow) is
|
|
151
|
+
// refused, by construction, without anyone having to remember to tighten a flag.
|
|
152
|
+
export const INCOMPLETE_REASON_CODES = ['sandbox-metrics-unavailable', 'manual-entry'] as const;
|
|
153
|
+
export type IncompleteReasonCode = typeof INCOMPLETE_REASON_CODES[number];
|
|
154
|
+
|
|
144
155
|
export type RecordKind = 'ledger' | 'training-pair';
|
|
145
156
|
|
|
146
157
|
export type RecordVerdict =
|
|
@@ -290,10 +301,22 @@ export function decideRecordWrite(input: {
|
|
|
290
301
|
/** measurement-integrity FR-5/FR-6: rollout-log + price enrichment for a ledger row. Absent ⇒ zero
|
|
291
302
|
* behavior change (NFR-1). */
|
|
292
303
|
enrich?: LedgerEnrichInput;
|
|
293
|
-
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
|
|
304
|
+
/**
|
|
305
|
+
* instrument-round-b FR-4/A5 (ADR-001 D4), fix-round-1 (Codex r1 HIGH finding 3): an AUTO ledger
|
|
306
|
+
* row that would be written `complete:false` is refused (exit 2, before any write) UNLESS the
|
|
307
|
+
* actual incompleteness is fully covered by this NAMED, SCOPED allowance — the CLI's
|
|
308
|
+
* `--allow-incomplete <fields>`. A field this row is incomplete in that is NOT in this set still
|
|
309
|
+
* refuses, naming exactly the uncovered field(s). `--no-strict` (a blanket opt-out) is gone —
|
|
310
|
+
* see {@link INCOMPLETE_REASON_CODES}.
|
|
311
|
+
*/
|
|
312
|
+
allowIncomplete?: readonly string[];
|
|
313
|
+
/**
|
|
314
|
+
* instrument-round-b fix-round-1 (Codex r1 HIGH finding 3): WHY the row is legitimately
|
|
315
|
+
* incomplete — the CLI's `--incomplete-reason <code>`, required alongside `allowIncomplete` and
|
|
316
|
+
* validated against the closed {@link INCOMPLETE_REASON_CODES} set. An unrecognized or absent
|
|
317
|
+
* reason refuses the write even when every incomplete field IS named in `allowIncomplete`.
|
|
318
|
+
*/
|
|
319
|
+
incompleteReason?: string | null;
|
|
297
320
|
}): RecordDecision {
|
|
298
321
|
const { kind, payloadRaw, stage } = input;
|
|
299
322
|
if (kind !== 'ledger' && kind !== 'training-pair') {
|
|
@@ -354,7 +377,20 @@ export function decideRecordWrite(input: {
|
|
|
354
377
|
// carry `auto:true` too (not just gate on it transiently), so every downstream reader of the
|
|
355
378
|
// PERSISTED line keeps seeing the same signal `shapeMismatch` already gated on above. A no-op when
|
|
356
379
|
// the payload already said `auto:true` (shapeMismatch already refused any OTHER value).
|
|
357
|
-
|
|
380
|
+
// Lead delta after Codex r2 (N1 HIGH): `auto:true` used to be indistinguishable between "the
|
|
381
|
+
// CLI-level trusted marker was present" and "a hand-built payload said so", so a manual row could
|
|
382
|
+
// read as an automatic one. STRIPPING the claim was tried and rejected: the minutes-delta contract
|
|
383
|
+
// (auto + runId) legitimately rides payload-declared auto rows, and 28 existing callers write
|
|
384
|
+
// them. So the claim SURVIVES and its PROVENANCE is recorded instead — `autoSource` is the field a
|
|
385
|
+
// reader filters on when it needs trusted automatic rows only.
|
|
386
|
+
if (kind === 'ledger') {
|
|
387
|
+
if (input.auto === true) {
|
|
388
|
+
stamped['auto'] = true;
|
|
389
|
+
stamped['autoSource'] = 'cli-flag';
|
|
390
|
+
} else if (stamped['auto'] === true) {
|
|
391
|
+
stamped['autoSource'] = 'payload-claim';
|
|
392
|
+
}
|
|
393
|
+
}
|
|
358
394
|
const isGap = (v: unknown): boolean => v === null || v === undefined || (typeof v === 'string' && v.trim() === '');
|
|
359
395
|
if (input.timestamp != null && input.timestamp !== '') {
|
|
360
396
|
// An EMPTY STRING is a gap, not a value. Stamping only over null/undefined let
|
|
@@ -598,11 +634,43 @@ export function decideRecordWrite(input: {
|
|
|
598
634
|
? [...new Set([...existingReasons, ...incompleteReasons])]
|
|
599
635
|
: incompleteReasons;
|
|
600
636
|
const complete = mergedReasons.length === 0 && !existingWasIncomplete;
|
|
601
|
-
// A3
|
|
602
|
-
//
|
|
603
|
-
// the row
|
|
604
|
-
|
|
605
|
-
|
|
637
|
+
// A3/A5 (круг B, ADR-001 D4), fix-round-1 (Codex r1 HIGH finding 3, finding 9): incompleteness
|
|
638
|
+
// is a REFUSAL by DEFAULT, before any write — but the escape hatch is now a NAMED, SCOPED
|
|
639
|
+
// allowance, not a blanket flag: the row writes only when EVERY name in `mergedReasons` is
|
|
640
|
+
// covered by `input.allowIncomplete` AND `input.incompleteReason` is one of the closed
|
|
641
|
+
// {@link INCOMPLETE_REASON_CODES}. This is the load-bearing condition finding 9 asked for a
|
|
642
|
+
// real discriminating mutation against — an omitted allowance (the ordinary caller who never
|
|
643
|
+
// heard of this option) refuses exactly like круг B's original default; a caller naming the
|
|
644
|
+
// wrong reason, or a wider incompleteness than it declared, ALSO refuses.
|
|
645
|
+
// Lead delta after Codex r2 (N3 MEDIUM): the closed-list check used to live INSIDE the
|
|
646
|
+
// incomplete branch, so a COMPLETE row could carry `--incomplete-reason bogus` and be written
|
|
647
|
+
// with an unrecognized code sitting in its arguments. A supplied reason is validated whenever it
|
|
648
|
+
// is supplied, complete or not.
|
|
649
|
+
const suppliedReason = input.incompleteReason ?? null;
|
|
650
|
+
if (suppliedReason !== null && !(INCOMPLETE_REASON_CODES as readonly string[]).includes(suppliedReason)) {
|
|
651
|
+
return refuse(`--incomplete-reason ${JSON.stringify(suppliedReason)} is not a recognized reason code — expected one of ${INCOMPLETE_REASON_CODES.join(', ')}`);
|
|
652
|
+
}
|
|
653
|
+
if (!complete) {
|
|
654
|
+
const allowedFields = new Set(input.allowIncomplete ?? []);
|
|
655
|
+
const reason = suppliedReason;
|
|
656
|
+
const reasonKnown = reason !== null && (INCOMPLETE_REASON_CODES as readonly string[]).includes(reason);
|
|
657
|
+
// Lead delta after Codex r2 (N2 HIGH): a payload marked `complete:false` with NO concrete
|
|
658
|
+
// reasons cannot be covered by any allowance — the subset relation has nothing to check, so
|
|
659
|
+
// `uncovered` came out empty and the row slipped through. An unexplained incompleteness is a
|
|
660
|
+
// refusal: name the fields, or do not claim incompleteness.
|
|
661
|
+
if (mergedReasons.length === 0) {
|
|
662
|
+
return refuse('auto ledger row is marked `complete:false` but names no incompleteReasons — an allowance cannot cover an unnamed gap; list the incomplete field(s) or drop the claim');
|
|
663
|
+
}
|
|
664
|
+
const uncovered = mergedReasons.filter((r) => !allowedFields.has(r));
|
|
665
|
+
if (uncovered.length > 0 || !reasonKnown) {
|
|
666
|
+
if (reason === null && allowedFields.size === 0) {
|
|
667
|
+
return refuse(`auto ledger row is incomplete (${mergedReasons.join(', ') || 'previously marked incomplete'}) — refused by default; pass --allow-incomplete <fields> and --incomplete-reason <code> to permit a scoped incomplete write`);
|
|
668
|
+
}
|
|
669
|
+
if (!reasonKnown) {
|
|
670
|
+
return refuse(`--incomplete-reason ${JSON.stringify(reason)} is not a recognized reason code — expected one of ${INCOMPLETE_REASON_CODES.join(', ')}`);
|
|
671
|
+
}
|
|
672
|
+
return refuse(`auto ledger row is incomplete in field(s) not covered by --allow-incomplete: ${uncovered.join(', ')} (incomplete: ${mergedReasons.join(', ') || 'previously marked incomplete'})`);
|
|
673
|
+
}
|
|
606
674
|
}
|
|
607
675
|
stamped['complete'] = complete;
|
|
608
676
|
if (!complete) stamped['incompleteReasons'] = mergedReasons.length > 0 ? mergedReasons : existingReasons;
|
package/src/score.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* PURE: the CLI reads the artifact files; this module only classifies.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { maskMarkdown } from './markdown-masker.js';
|
|
23
24
|
import { amendmentIdsIn } from './amendment-trace.js';
|
|
24
25
|
import { QE_SEVERITIES, findInvalidQeVerdictLines, parseQeFindings, readQeVerdictLines, type QeFindingsRefusedRow, type QeFindingsSummary } from './qe-findings.js';
|
|
25
26
|
|
|
@@ -103,7 +104,13 @@ export function observabilityAnswer(architectureMarkdown: string | undefined | n
|
|
|
103
104
|
// Fenced blocks are stripped FIRST: a `## Observability` inside an example fence is not a section
|
|
104
105
|
// of this document, and accepting one is a false pass anybody could write by accident
|
|
105
106
|
// (cross-family review, finding 1).
|
|
106
|
-
|
|
107
|
+
// Block scoping is DELEGATED to the canonical masker, not a local paired-fence regex. The regex
|
|
108
|
+
// it replaces required a MATCHING CLOSE, so an UNCLOSED fence stripped nothing at all and a
|
|
109
|
+
// `## Observability` quoted inside the example counted as a real section — MEASURED 2026-09-20,
|
|
110
|
+
// exactly the false pass the comment above warns about. `unclosed: 'hide'` is the CommonMark
|
|
111
|
+
// reading (an unclosed fence runs to end of document) and is the safe direction for a gate:
|
|
112
|
+
// a section that may be code must not be accepted as an answer.
|
|
113
|
+
const text = String(maskMarkdown(String(architectureMarkdown ?? '').replace(/\r\n/g, '\n'), { unclosed: 'hide' }));
|
|
107
114
|
// CommonMark: up to three leading spaces, then #s, then REQUIRED whitespace. `##Observability` is
|
|
108
115
|
// not a heading and must not pass; an indented one is, and must not be missed (finding 2).
|
|
109
116
|
const headingRe = new RegExp('^ {0,3}#{1,6}[ \t]+' + OBSERVABILITY_SECTION + '\\b.*$', 'gim');
|
package/src/sign.ts
CHANGED
|
@@ -769,3 +769,56 @@ export function decideVerifyPolicy(verdict: PackVerdict, requireSigning: boolean
|
|
|
769
769
|
? { action: 'fail', reason: 'pack is unsigned and --require-signing was passed' }
|
|
770
770
|
: { action: 'report', reason: 'pack is unsigned — not verified' };
|
|
771
771
|
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* Файлы, которые npm НИКОГДА не кладёт в тарбол, а рабочее дерево несёт почти всегда.
|
|
775
|
+
* Список намеренно короткий: каждая позиция — не эвристика вкуса, а правило упаковщика или
|
|
776
|
+
* общепринятый файл разработки, отсутствующий в опубликованном артефакте.
|
|
777
|
+
*/
|
|
778
|
+
const DEV_TREE_MARKERS: readonly string[] = [
|
|
779
|
+
'node_modules', // npm исключает ВСЕГДА, безусловно
|
|
780
|
+
'.git',
|
|
781
|
+
'tsconfig.json',
|
|
782
|
+
'tsconfig.tsbuildinfo',
|
|
783
|
+
'vitest.config.ts',
|
|
784
|
+
'vitest.config.mts',
|
|
785
|
+
'vitest.config.js',
|
|
786
|
+
'CHANGELOG.md',
|
|
787
|
+
'test',
|
|
788
|
+
'tests',
|
|
789
|
+
];
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Объяснение к ПРОВАЛИВШЕЙСЯ проверке подписи, когда каталог похож на рабочее дерево.
|
|
793
|
+
*
|
|
794
|
+
* ЗАЧЕМ ЭТО СУЩЕСТВУЕТ. Манифест описывает ОПУБЛИКОВАННЫЙ ТАРБОЛ, а не рабочее дерево: при
|
|
795
|
+
* публикации package.json переписывается (`workspace:*` разворачивается, версия бампается), а
|
|
796
|
+
* файлы разработки отфильтровываются. Поэтому сверка неотфильтрованного дерева расходится
|
|
797
|
+
* ПО ПОСТРОЕНИЮ, и вывод выглядит как «подпись сломана» или «дерево подменили».
|
|
798
|
+
*
|
|
799
|
+
* ИЗМЕРЕНО 2026-09-01: обход 55 подписанных пакетов в рабочем дереве дал 54 отказа из 55, и за
|
|
800
|
+
* один день на это независимо попались ТРОЕ — ведущий агент и два роя; один внёс это в отчёт
|
|
801
|
+
* как системный дефект подписей, другой предложил завести задачу, третий чуть не поднял тревогу
|
|
802
|
+
* о взломе доверия. Инструмент, трижды за день обманувший опытного пользователя, дефектен сам.
|
|
803
|
+
*
|
|
804
|
+
* ПОЧЕМУ ЭТО ОБЪЯСНЕНИЕ, А НЕ КЛАССИФИКАЦИЯ. Соседний путь (обход установленных пакетов) считает
|
|
805
|
+
* исходным деревом всё, что лежит не под `node_modules`. Здесь так нельзя: законно распакованный
|
|
806
|
+
* тарбол в /tmp тоже лежит не под `node_modules`, и его объявили бы деревом. Поэтому функция
|
|
807
|
+
* НИЧЕГО НЕ УТВЕРЖДАЕТ о природе каталога — она НАЗЫВАЕТ НАЙДЕННЫЕ УЛИКИ и оставляет вывод
|
|
808
|
+
* человеку. Нет улик — нет и объяснения: молчание здесь честнее догадки.
|
|
809
|
+
*
|
|
810
|
+
* @param entries имена верхнего уровня в проверяемом каталоге
|
|
811
|
+
* @returns текст пояснения или null, если улик рабочего дерева не нашлось
|
|
812
|
+
*/
|
|
813
|
+
export function explainPackVerificationFailure(entries: readonly string[]): string | null {
|
|
814
|
+
const found = DEV_TREE_MARKERS.filter((marker) => entries.includes(marker));
|
|
815
|
+
if (found.length === 0) return null;
|
|
816
|
+
return [
|
|
817
|
+
'',
|
|
818
|
+
'Прежде чем читать это как подделку: манифест описывает ОПУБЛИКОВАННЫЙ ТАРБОЛ, а не рабочее дерево.',
|
|
819
|
+
`Этот каталог несёт файлы разработки, которых в тарболе нет: ${found.join(', ')}.`,
|
|
820
|
+
'При публикации package.json переписывается, а файлы разработки отфильтровываются, поэтому',
|
|
821
|
+
'сверка неотфильтрованного дерева расходится ПО ПОСТРОЕНИЮ — это не улика подделки.',
|
|
822
|
+
'Проверять надо артефакт: npm pack <имя>@<версия> → распаковать → dz verify-pack --pack <распакованное>.',
|
|
823
|
+
].join('\n');
|
|
824
|
+
}
|