@dzhechkov/harness-core 0.8.38 → 0.8.40
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 +281 -161
- package/README.md +145 -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/cmd-usage.d.ts.map +1 -1
- package/dist/cmd-usage.js +48 -2
- package/dist/cmd-usage.js.map +1 -1
- package/dist/compounding.d.ts +66 -0
- package/dist/compounding.d.ts.map +1 -1
- package/dist/compounding.js +76 -9
- package/dist/compounding.js.map +1 -1
- package/dist/doctor-instrument.d.ts +65 -0
- package/dist/doctor-instrument.d.ts.map +1 -0
- package/dist/doctor-instrument.js +91 -0
- package/dist/doctor-instrument.js.map +1 -0
- 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/feature-tier.d.ts.map +1 -1
- package/dist/feature-tier.js +13 -1
- package/dist/feature-tier.js.map +1 -1
- package/dist/guard.d.ts +17 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +15 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -5
- 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 +59 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +65 -1
- 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/operations.d.ts +13 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +88 -2
- package/dist/operations.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/registry.d.ts +58 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +62 -5
- package/dist/registry.js.map +1 -1
- package/dist/release.d.ts +20 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +48 -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/round-exec.d.ts +10 -0
- package/dist/round-exec.d.ts.map +1 -1
- package/dist/round-exec.js +3 -2
- package/dist/round-exec.js.map +1 -1
- package/dist/round.d.ts +19 -0
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +1 -0
- package/dist/round.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 +460 -160
- 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/cmd-usage.ts +52 -2
- package/src/compounding.ts +112 -9
- package/src/doctor-instrument.ts +153 -0
- 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/feature-tier.ts +14 -1
- package/src/guard.ts +24 -0
- package/src/index.ts +22 -5
- package/src/loop-blobs.generated.ts +2 -2
- package/src/mutation-gate.ts +113 -1
- package/src/name-check.ts +43 -1
- package/src/no-stubs.ts +14 -5
- package/src/operations.ts +85 -3
- 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/registry.ts +91 -1
- package/src/release.ts +54 -4
- package/src/reqe.ts +11 -1
- package/src/round-exec.ts +13 -2
- package/src/round.ts +20 -0
- 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/mutation-gate.ts
CHANGED
|
@@ -494,6 +494,12 @@ export interface MutationObservation {
|
|
|
494
494
|
readonly outputError?: string;
|
|
495
495
|
/** bounded log proving an internal runner failure received at most one retry. */
|
|
496
496
|
readonly internalAttemptLog?: string;
|
|
497
|
+
/**
|
|
498
|
+
* Whether any suite the entry's `testCommand` selects NAMES the mutated module — see
|
|
499
|
+
* {@link suiteSelectionNamesModule}. Only read on the UNDEFENDED path, and only to add a hint:
|
|
500
|
+
* `false` says "check the command before the tests", never "the property is fine".
|
|
501
|
+
*/
|
|
502
|
+
readonly suiteNamesModule?: boolean | 'unknown';
|
|
497
503
|
}
|
|
498
504
|
|
|
499
505
|
export interface MutationEntryResult {
|
|
@@ -1333,7 +1339,13 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
|
|
|
1333
1339
|
applied: true,
|
|
1334
1340
|
verdict: 'UNDEFENDED',
|
|
1335
1341
|
drop: false,
|
|
1336
|
-
detail: `suite stayed GREEN with the protection deleted — property UNDEFENDED: "${e.property}" (${e.file}). The suite would not notice this protection regressing (rule 2)
|
|
1342
|
+
detail: `suite stayed GREEN with the protection deleted — property UNDEFENDED: "${e.property}" (${e.file}). The suite would not notice this protection regressing (rule 2).`
|
|
1343
|
+
+ (obs.suiteNamesModule === false
|
|
1344
|
+
? ' HINT: no suite in this entry\'s testCommand NAMES this module, so check the COMMAND before the tests'
|
|
1345
|
+
+ ' — a suite that never loads the module cannot notice its protection (MEASURED 2026-09-04: two properties'
|
|
1346
|
+
+ ' read as UNDEFENDED for exactly this reason, and both tests existed). The hint matches the module stem,'
|
|
1347
|
+
+ ' so a transitive import would not be seen by it: it narrows where to look, it does not decide.'
|
|
1348
|
+
: ''),
|
|
1337
1349
|
};
|
|
1338
1350
|
}
|
|
1339
1351
|
|
|
@@ -1408,6 +1420,47 @@ function classifyMutationOutcomeWithoutAttemptLog(obs: MutationObservation): Mut
|
|
|
1408
1420
|
};
|
|
1409
1421
|
}
|
|
1410
1422
|
|
|
1423
|
+
/**
|
|
1424
|
+
* Suite paths a registry `testCommand` selects, in order. Tokens that are not suite files (the
|
|
1425
|
+
* runner, its flags) are ignored — the command is a shell line, not a schema, so this reads the
|
|
1426
|
+
* shape it actually has rather than assuming one.
|
|
1427
|
+
*/
|
|
1428
|
+
export function parseSuitePaths(testCommand: string): readonly string[] {
|
|
1429
|
+
return String(testCommand ?? '')
|
|
1430
|
+
.split(/\s+/)
|
|
1431
|
+
.filter((token) => /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(token) && !token.startsWith('-'));
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Does ANY suite the command selects even name the mutated module?
|
|
1436
|
+
*
|
|
1437
|
+
* Why this exists: `UNDEFENDED` reads as "this property has no test", and MEASURED 2026-09-04 that
|
|
1438
|
+
* reading was wrong twice in one run — both tests existed; the registry's `testCommand` simply did
|
|
1439
|
+
* not select the suites that import them (backlog 1f4e4f66). The author filed a finding about two
|
|
1440
|
+
* "unprotected properties" before checking the instrument, which is the failure this hint prevents.
|
|
1441
|
+
*
|
|
1442
|
+
* Deliberately a HINT, never a verdict: it matches the module's STEM in each suite's text, so a
|
|
1443
|
+
* suite that reaches the module through a transitive import is invisible to it. `'unknown'` when no
|
|
1444
|
+
* suite could be read — absence of evidence is not evidence, and a hint that guesses is worse than
|
|
1445
|
+
* no hint.
|
|
1446
|
+
*/
|
|
1447
|
+
export function suiteSelectionNamesModule(input: {
|
|
1448
|
+
readonly file: string;
|
|
1449
|
+
readonly suitePaths: readonly string[];
|
|
1450
|
+
readonly readSuite: (path: string) => string | null;
|
|
1451
|
+
}): boolean | 'unknown' {
|
|
1452
|
+
const stem = String(input.file ?? '').split('/').pop()?.replace(/\.[cm]?[jt]sx?$/, '') ?? '';
|
|
1453
|
+
if (stem === '') return 'unknown';
|
|
1454
|
+
let read = 0;
|
|
1455
|
+
for (const suite of input.suitePaths) {
|
|
1456
|
+
const text = input.readSuite(suite);
|
|
1457
|
+
if (text === null) continue;
|
|
1458
|
+
read += 1;
|
|
1459
|
+
if (text.includes(stem)) return true;
|
|
1460
|
+
}
|
|
1461
|
+
return read === 0 ? 'unknown' : false;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1411
1464
|
export function classifyMutationOutcome(obs: MutationObservation): MutationEntryResult {
|
|
1412
1465
|
const result = classifyMutationOutcomeWithoutAttemptLog(obs);
|
|
1413
1466
|
if (obs.internalAttemptLog === undefined) return result;
|
|
@@ -1488,6 +1541,65 @@ export function summarizeMutationResults(results: readonly MutationEntryResult[]
|
|
|
1488
1541
|
};
|
|
1489
1542
|
}
|
|
1490
1543
|
|
|
1544
|
+
// ── Durable verdicts (instrument-round-b T3/FR-3/A3/A4, ADR-001 D3) ────────────────────────────
|
|
1545
|
+
//
|
|
1546
|
+
// The registry (`test/mutation-registry.json`) is the gate's INPUT — what to mutate. A verdict is
|
|
1547
|
+
// its OUTPUT — what happened the last time the gate ran. Mixing them means the input would change
|
|
1548
|
+
// on every run and every reader of the registry (mutation-registry-freshness included) would have
|
|
1549
|
+
// to start tolerating a moving file; A4 keeps them apart on purpose. `dz mutation-gate` (the CLI
|
|
1550
|
+
// executor) appends one JSON line per classified entry to a SEPARATE durable file
|
|
1551
|
+
// (`.dz/mutation-gate/verdicts.jsonl`) so a verdict survives the executor's own scratch-dir
|
|
1552
|
+
// teardown and the host's next reboot — today it lives only in `DZ_MUTGATE_OUTPUT_DIR`, a temp
|
|
1553
|
+
// directory (00_complexity_assessment.md: 0 of 377 registry entries carry a persisted verdict).
|
|
1554
|
+
|
|
1555
|
+
/** Everything the CLI knows that this pure module does not: when, in which package, and under
|
|
1556
|
+
* which run. `runId` is honest-absent (`null`) when the caller has none to offer — never guessed. */
|
|
1557
|
+
export interface MutationVerdictMeta {
|
|
1558
|
+
readonly ts: string;
|
|
1559
|
+
readonly package: string;
|
|
1560
|
+
readonly runId: string | null;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** One durable line — append-only, one per classified registry entry. `observed` is the
|
|
1564
|
+
* registry's OWN anchor (copied, never recomputed) so a reader can see the historical coverage
|
|
1565
|
+
* claim next to the fresh verdict without re-opening the registry. */
|
|
1566
|
+
export interface MutationVerdictRow {
|
|
1567
|
+
readonly ts: string;
|
|
1568
|
+
readonly package: string;
|
|
1569
|
+
readonly entryId: string;
|
|
1570
|
+
readonly verdict: MutationVerdict;
|
|
1571
|
+
readonly failingCount: number | null;
|
|
1572
|
+
readonly observed: number | null;
|
|
1573
|
+
readonly drop: boolean;
|
|
1574
|
+
readonly dropComparable: boolean;
|
|
1575
|
+
readonly runId: string | null;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* Pure: payload in, row out — no filesystem (NFR-2; the CLI owns the append). `entry` is `null`
|
|
1580
|
+
* for a result that never resolved to a valid registry entry (`ENTRY_INVALID` / `COVERAGE_GAP` —
|
|
1581
|
+
* `parseMutationRegistry` excludes those from `registry.entries` by construction, so the CALLER
|
|
1582
|
+
* cannot always hand one in) — `observed` then stays `null`, honestly, rather than the caller
|
|
1583
|
+
* inventing a fallback entry object just to satisfy this signature.
|
|
1584
|
+
*/
|
|
1585
|
+
export function mutationVerdictRow(
|
|
1586
|
+
entry: MutationRegistryEntry | null,
|
|
1587
|
+
result: MutationEntryResult,
|
|
1588
|
+
meta: MutationVerdictMeta,
|
|
1589
|
+
): MutationVerdictRow {
|
|
1590
|
+
return {
|
|
1591
|
+
ts: meta.ts,
|
|
1592
|
+
package: meta.package,
|
|
1593
|
+
entryId: result.id,
|
|
1594
|
+
verdict: result.verdict,
|
|
1595
|
+
failingCount: result.failingCount,
|
|
1596
|
+
observed: entry !== null && typeof entry.observed === 'number' ? entry.observed : null,
|
|
1597
|
+
drop: result.drop,
|
|
1598
|
+
dropComparable: result.dropComparable,
|
|
1599
|
+
runId: meta.runId,
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1491
1603
|
const VERDICT_MARK: Record<MutationVerdict, string> = {
|
|
1492
1604
|
PROVEN: '✓',
|
|
1493
1605
|
ENTRY_INVALID: '✗',
|
package/src/name-check.ts
CHANGED
|
@@ -127,7 +127,37 @@ export function decideNameCheck(queries: readonly NameQuery[], facts: NameFacts)
|
|
|
127
127
|
return { outcome: 'free', exit: 0, results, reason: `all ${results.length} name(s) are free` };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Имена того же вида, ДЕЛЯЩИЕ СЛОВО с запрошенным.
|
|
132
|
+
*
|
|
133
|
+
* ЗАЧЕМ ЭТО ЗДЕСЬ. `name-check` отвечает на вопрос «занято ли ИМЯ», и отвечает верно. Но у
|
|
134
|
+
* него есть соседний вопрос, которого он не задаёт: «а нет ли уже ПРИБОРА для этой работы».
|
|
135
|
+
* ИЗМЕРЕНО 2026-09-19: `dz name-check --module ledger-cost-fill` честно ответил FREE, и по
|
|
136
|
+
* этому ответу был построен модуль, дублирующий существующий `ledger-backfill` — подключённый
|
|
137
|
+
* к CLI, с тем же измерением в шапке, заполняющий 47 строк против 31 у дубля и вдобавок
|
|
138
|
+
* намеренно НЕ делающий того, что дубль сделал (вывод минут и агентов, тихо переопределивший
|
|
139
|
+
* две колонки).
|
|
140
|
+
*
|
|
141
|
+
* Поэтому здесь НЕ вердикт и НЕ отказ — свободное имя остаётся свободным. Это СВЕДЕНИЯ в точке
|
|
142
|
+
* решения: список соседей по корню слова, которые оператор увидит ровно тогда, когда ещё не
|
|
143
|
+
* написал ни строки. Разница между «занято» и «рядом есть похожее» сохранена намеренно:
|
|
144
|
+
* превратить второе в отказ значило бы запретить `publish-order` рядом с `publish-signing`.
|
|
145
|
+
*
|
|
146
|
+
* Слова короче четырёх букв игнорируются: `fa`, `dz`, `to`, `run` роднят почти всё со всем.
|
|
147
|
+
*/
|
|
148
|
+
export function nearNames(query: NameQuery, facts: NameFacts, limit = 5): string[] {
|
|
149
|
+
const words = (n: string): string[] =>
|
|
150
|
+
n.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4);
|
|
151
|
+
const asked = new Set(words(query.name));
|
|
152
|
+
if (asked.size === 0) return [];
|
|
153
|
+
const pool: string[] = query.kind === 'command'
|
|
154
|
+
? [...facts.commands]
|
|
155
|
+
: query.kind === 'module' ? [...facts.modules.keys()] : [...facts.exports.keys()];
|
|
156
|
+
const hits = pool.filter((n) => n !== query.name && words(n).some((w) => asked.has(w)));
|
|
157
|
+
return hits.sort().slice(0, limit);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function renderNameCheck(decision: NameDecision, scanned?: NameFacts['scanned'], facts?: NameFacts): string[] {
|
|
131
161
|
const out: string[] = [];
|
|
132
162
|
if (scanned !== undefined) {
|
|
133
163
|
// Printed always, pass or fail: the operator must be able to see that the sweep looked at a real
|
|
@@ -145,6 +175,18 @@ export function renderNameCheck(decision: NameDecision, scanned?: NameFacts['sca
|
|
|
145
175
|
// Said on the passing path, because that is where the limit gets forgotten: the scan reads
|
|
146
176
|
// declarations, so a re-export under a different name (`export { a as b }`) is invisible to it.
|
|
147
177
|
out.push(' note: this reads declarations in source. A re-export under a different name is not visible here — the build still owns that case.');
|
|
178
|
+
if (facts !== undefined) {
|
|
179
|
+
// Свободное имя — не доказательство отсутствия прибора. Соседи печатаются СВЕДЕНИЯМИ,
|
|
180
|
+
// вердикт при этом не меняется: отказывать за сходство значило бы запретить
|
|
181
|
+
// `publish-order` рядом с `publish-signing`.
|
|
182
|
+
for (const r of decision.results) {
|
|
183
|
+
const near = nearNames({ kind: r.kind, name: r.name }, facts);
|
|
184
|
+
if (near.length > 0) {
|
|
185
|
+
out.push(` рядом уже есть ${r.kind}(s), делящие слово с «${r.name}»: ${near.join(', ')}`);
|
|
186
|
+
out.push(' прочитайте их ПЕРЕД тем, как писать: свободное ИМЯ не означает отсутствия ПРИБОРА для этой работы');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
148
190
|
}
|
|
149
191
|
return out;
|
|
150
192
|
}
|
package/src/no-stubs.ts
CHANGED
|
@@ -118,6 +118,8 @@ export interface StubFinding {
|
|
|
118
118
|
readonly detail: string;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
import { maskMarkdown } from './markdown-masker.js';
|
|
122
|
+
|
|
121
123
|
/** A config waiver: path-keyed, reason MANDATORY. A reasonless entry is refused, never honoured. */
|
|
122
124
|
export interface StubWaiver {
|
|
123
125
|
readonly path?: string;
|
|
@@ -141,18 +143,25 @@ function maskInlineCode(line: string): string {
|
|
|
141
143
|
* clean, while a naked stub line in doc prose still fires. Code files are scanned in full: a marker
|
|
142
144
|
* a code file must legitimately carry (another gate's source, a fixture) takes an inline
|
|
143
145
|
* `no-stubs: <reason>` waiver — visible, reasoned, greppable.
|
|
146
|
+
*
|
|
147
|
+
* Block scoping is DELEGATED to the canonical masker, not re-implemented here. The hand-rolled
|
|
148
|
+
* `inFence = !inFence` toggle it replaces broke two CommonMark rules and produced MEASURED false
|
|
149
|
+
* positives (2026-09-20): a fence closes only on its OWN marker, so a `~~~` line inside a backtick
|
|
150
|
+
* block is content — the toggle read it as a close and scanned the rest of the code block as prose;
|
|
151
|
+
* and a closing fence may carry NO info string, so a second info-string line closed the block.
|
|
152
|
+
* `unclosed: 'hide'` preserves THIS reader's existing policy — an unclosed opener hides the tail
|
|
153
|
+
* rather than restoring it, because a gate must not invent findings out of a half-written block.
|
|
154
|
+
* Four-space indented code stays unmasked (the masker default): unchanged from the toggle era, a
|
|
155
|
+
* known limit rather than a new one.
|
|
144
156
|
*/
|
|
145
157
|
export function scanStubs(path: string, text: unknown): StubFinding[] {
|
|
146
158
|
if (typeof text !== 'string' || text.length === 0) return [];
|
|
147
159
|
const md = isMarkdown(path);
|
|
148
160
|
const out: StubFinding[] = [];
|
|
149
|
-
let inFence = false;
|
|
150
161
|
const lines = text.split('\n');
|
|
162
|
+
const scanLines = md ? String(maskMarkdown(text, { unclosed: 'hide' })).split('\n') : lines;
|
|
151
163
|
for (let i = 0; i < lines.length; i++) {
|
|
152
|
-
const
|
|
153
|
-
if (md && /^\s*(```|~~~)/.test(raw)) { inFence = !inFence; continue; }
|
|
154
|
-
if (md && inFence) continue;
|
|
155
|
-
const line = md ? maskInlineCode(raw) : raw;
|
|
164
|
+
const line = md ? maskInlineCode(scanLines[i] ?? '') : lines[i]!;
|
|
156
165
|
|
|
157
166
|
// Inline waiver first: with a reason ⇒ the line is exempt; without one ⇒ refuse loudly AND
|
|
158
167
|
// leave the marker finding in place (a refused waiver must not half-work).
|
package/src/operations.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { execFileSync, spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'node:child_process';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
12
12
|
import { homedir, tmpdir } from 'node:os';
|
|
13
13
|
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
@@ -51,6 +51,7 @@ import { WINDSURF_RULES_ROOT } from '@dzhechkov/adapter-windsurf';
|
|
|
51
51
|
import { AGENTS_MD_BLOCK_BEGIN, mergeAgentsMd, mergeGeminiMd, mergePolicyBlock, renderAgentsMdSection } from '@dzhechkov/core';
|
|
52
52
|
import type { CanonicalSkill, EmitResult, SkillAsset } from '@dzhechkov/core';
|
|
53
53
|
import { computeRiskScore } from './risk-scoring.js';
|
|
54
|
+
import { checkInstrumentFreshness, checkRankingState } from './doctor-instrument.js';
|
|
54
55
|
|
|
55
56
|
import { applyEmitResult } from './apply.js';
|
|
56
57
|
import { describeSkillLoadFailure, discoverSkillIds, loadSkillFromDir } from './skills.js';
|
|
@@ -1039,6 +1040,12 @@ export function runMigrate(options: { projectRoot: string }): MigrateReport {
|
|
|
1039
1040
|
export interface DoctorCheck {
|
|
1040
1041
|
readonly name: string;
|
|
1041
1042
|
readonly ok: boolean;
|
|
1043
|
+
/**
|
|
1044
|
+
* Rendering severity for a check that is NOT a failure. `warn` = measured and worth saying out
|
|
1045
|
+
* loud; `unknown` = the evidence could not be gathered, which is never rendered as a pass. Neither
|
|
1046
|
+
* value participates in `DoctorReport.ok`, so neither can change any caller's exit code.
|
|
1047
|
+
*/
|
|
1048
|
+
readonly level?: 'warn' | 'unknown';
|
|
1042
1049
|
readonly detail: string;
|
|
1043
1050
|
}
|
|
1044
1051
|
|
|
@@ -1050,7 +1057,13 @@ export interface DoctorReport {
|
|
|
1050
1057
|
}
|
|
1051
1058
|
|
|
1052
1059
|
/** Report environment diagnostics for the harness. */
|
|
1053
|
-
|
|
1060
|
+
/**
|
|
1061
|
+
* `instrumentPath` is the executable that is answering — normally the CLI's own `process.argv[1]`.
|
|
1062
|
+
* It is an INPUT, not something this module reads for itself: `core-boundary` rule A forbids core
|
|
1063
|
+
* from touching process globals, because core is a library and the process belongs to whoever hosts
|
|
1064
|
+
* it. Omitted ⇒ the instrument-freshness check honestly reports that it could not tell.
|
|
1065
|
+
*/
|
|
1066
|
+
export async function runDoctor(options: { projectRoot: string; instrumentPath?: string | null }): Promise<DoctorReport> {
|
|
1054
1067
|
const checks: DoctorCheck[] = [];
|
|
1055
1068
|
const root = options.projectRoot;
|
|
1056
1069
|
|
|
@@ -1065,6 +1078,74 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1065
1078
|
// monorepo (or a fork of it) and owes itself these checks; anything else is a consumer project
|
|
1066
1079
|
// and gets a NAMED skip — a skip, never a silent pass and never a fail.
|
|
1067
1080
|
const isMonorepo = existsSync(join(root, 'packages', '@dzhechkov'));
|
|
1081
|
+
|
|
1082
|
+
// The executable is part of the measurement. Resolve the CALLER-SUPPLIED path (including its
|
|
1083
|
+
// symlinks), then attribute it to the nearest package.json. Every read is independent and
|
|
1084
|
+
// fail-quiet so a broken install becomes an explicit UNKNOWN diagnostic instead of throwing.
|
|
1085
|
+
let binPath: string | null = null;
|
|
1086
|
+
try {
|
|
1087
|
+
const given = options.instrumentPath;
|
|
1088
|
+
binPath = given === undefined || given === null ? null : realpathSync(given);
|
|
1089
|
+
} catch { /* unresolved is reported by the pure decider */ }
|
|
1090
|
+
let binVersion: string | null = null;
|
|
1091
|
+
if (binPath !== null) {
|
|
1092
|
+
try {
|
|
1093
|
+
let cursor = dirname(binPath);
|
|
1094
|
+
while (true) {
|
|
1095
|
+
const packagePath = join(cursor, 'package.json');
|
|
1096
|
+
if (existsSync(packagePath)) {
|
|
1097
|
+
const parsed = JSON.parse(readFileSync(packagePath, 'utf8')) as { version?: unknown };
|
|
1098
|
+
binVersion = typeof parsed.version === 'string' ? parsed.version : null;
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
const parent = dirname(cursor);
|
|
1102
|
+
if (parent === cursor) break;
|
|
1103
|
+
cursor = parent;
|
|
1104
|
+
}
|
|
1105
|
+
} catch { binVersion = null; }
|
|
1106
|
+
}
|
|
1107
|
+
let treeVersion: string | null = null;
|
|
1108
|
+
if (isMonorepo) {
|
|
1109
|
+
try {
|
|
1110
|
+
const parsed = JSON.parse(readFileSync(join(root, 'packages', '@dzhechkov', 'harness-cli', 'package.json'), 'utf8')) as { version?: unknown };
|
|
1111
|
+
treeVersion = typeof parsed.version === 'string' ? parsed.version : null;
|
|
1112
|
+
} catch { treeVersion = null; }
|
|
1113
|
+
}
|
|
1114
|
+
let realRoot = resolvePath(root);
|
|
1115
|
+
let realRootResolved = true;
|
|
1116
|
+
// A root that cannot be resolved through its symlinks makes the containment question undecidable
|
|
1117
|
+
// rather than false — the decider is told so explicitly instead of silently comparing two path
|
|
1118
|
+
// forms that need not agree.
|
|
1119
|
+
try { realRoot = realpathSync(root); } catch { realRootResolved = false; }
|
|
1120
|
+
const instrument = checkInstrumentFreshness({ binPath, binVersion, treeVersion, projectRoot: realRoot, projectRootRealpathed: realRootResolved, isMonorepo });
|
|
1121
|
+
checks.push({
|
|
1122
|
+
name: 'doctor instrument freshness',
|
|
1123
|
+
// The pure decider owns the verdict; this wiring only carries it. Never re-derive `ok` here.
|
|
1124
|
+
ok: true,
|
|
1125
|
+
...(instrument.level === 'ok' ? {} : { level: instrument.level }),
|
|
1126
|
+
detail: instrument.detail,
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
const rankingStatePath = join(realRoot, '.dz', 'lesson-bandit', 'state.json');
|
|
1130
|
+
let rankingFlagOn = false;
|
|
1131
|
+
try {
|
|
1132
|
+
const parsed = JSON.parse(readFileSync(join(root, '.dz', 'config.json'), 'utf8')) as {
|
|
1133
|
+
memory?: { learning?: { banditRerank?: unknown } };
|
|
1134
|
+
};
|
|
1135
|
+
rankingFlagOn = parsed.memory?.learning?.banditRerank === true;
|
|
1136
|
+
} catch { /* malformed config is owned by the config diagnostic; ranking defaults off */ }
|
|
1137
|
+
const ranking = checkRankingState({
|
|
1138
|
+
flagOn: rankingFlagOn,
|
|
1139
|
+
statePath: rankingStatePath,
|
|
1140
|
+
stateExists: existsSync(rankingStatePath),
|
|
1141
|
+
binPath,
|
|
1142
|
+
});
|
|
1143
|
+
checks.push({
|
|
1144
|
+
name: 'bandit ranking state',
|
|
1145
|
+
ok: true,
|
|
1146
|
+
...(ranking.level === 'warn' ? { level: 'warn' as const } : {}),
|
|
1147
|
+
detail: ranking.detail,
|
|
1148
|
+
});
|
|
1068
1149
|
checks.push({
|
|
1069
1150
|
name: '.claude/skills present',
|
|
1070
1151
|
ok: existsSync(join(root, '.claude', 'skills')),
|
|
@@ -1648,7 +1729,8 @@ export async function runDoctor(options: { projectRoot: string }): Promise<Docto
|
|
|
1648
1729
|
const text = readFileSync(p, 'utf-8');
|
|
1649
1730
|
const v = verifyEventChainText(text);
|
|
1650
1731
|
if (v.chained === 0 || v.ok) continue;
|
|
1651
|
-
|
|
1732
|
+
// The verifier already counted non-empty lines; re-deriving it drifts (see cli.ts note).
|
|
1733
|
+
const total = v.lines;
|
|
1652
1734
|
const age = classifyChainDefects(v, total);
|
|
1653
1735
|
const named = `${v.defects.length} defect(s): ${v.defects.slice(0, 3).map((d) => `${d.kind}@L${d.line}`).join(', ')}`;
|
|
1654
1736
|
// A break that an unbroken run has already outlived is not a reason to distrust today's
|
package/src/patterns.ts
CHANGED
|
@@ -755,10 +755,52 @@ export async function reinforcePattern(projectRoot: string, dzIdOrText: string,
|
|
|
755
755
|
return result;
|
|
756
756
|
}
|
|
757
757
|
|
|
758
|
+
/**
|
|
759
|
+
* Which learned pattern does `--reinforce <arg>` mean? PURE, so the branch that REFUSES has a test.
|
|
760
|
+
*
|
|
761
|
+
* Order: the full id, then the exact text, then a BARE id suffix.
|
|
762
|
+
*
|
|
763
|
+
* The suffix step exists because an id is PRINTED as `<namespace>:<suffix>` — `teach:1a4a9bdd…` —
|
|
764
|
+
* by every surface that emits one (`dz teach`'s own `ID:` line, `dz recall --json`'s `dzId`), and a
|
|
765
|
+
* caller who kept only the hex got `no learned pattern matches`. The advisory then pointed at
|
|
766
|
+
* `dz recall`, which cannot surface a QUARANTINED lesson by its own text, so a lesson taught
|
|
767
|
+
* minutes ago had no reachable path at all (MEASURED 2026-09-20, backlog 2d3059fc). The report's
|
|
768
|
+
* wider claim — that ids are rejected outright — is REFUTED: the full printed id has always worked.
|
|
769
|
+
*
|
|
770
|
+
* An ambiguous suffix is refused BY NAME, never guessed. Reinforcing the wrong lesson writes
|
|
771
|
+
* silently and leaves no signal afterwards that the wrong record moved, so a wrong guess here is
|
|
772
|
+
* strictly worse than a refusal the caller can act on. Today only `teach:` ids exist, but `dream:`
|
|
773
|
+
* is a second canonical namespace this store already documents, so the collision is reachable.
|
|
774
|
+
*/
|
|
775
|
+
export function resolveReinforceTarget(
|
|
776
|
+
records: readonly MemoryRecord[],
|
|
777
|
+
dzIdOrText: string,
|
|
778
|
+
): { rec: MemoryRecord; matchedBy: 'id' | 'text' | 'suffix' } | { rec: undefined; error: string } {
|
|
779
|
+
// The KIND of match is part of the answer, not a detail: the caller prints it in a receipt, and a
|
|
780
|
+
// receipt that says "matched by text" about a suffix match is a receipt that lies. MEASURED
|
|
781
|
+
// 2026-09-20: the CLI inferred the kind from `resolved !== input`, which is true for BOTH a text
|
|
782
|
+
// match and a suffix match, so the suffix path reported the wrong one.
|
|
783
|
+
const byId = records.find((r) => r.id === dzIdOrText);
|
|
784
|
+
if (byId !== undefined) return { rec: byId, matchedBy: 'id' };
|
|
785
|
+
const byText = records.find((r) => r.text === dzIdOrText);
|
|
786
|
+
if (byText !== undefined) return { rec: byText, matchedBy: 'text' };
|
|
787
|
+
const bare = records.filter((r) => typeof r.id === 'string' && r.id.endsWith(':' + dzIdOrText));
|
|
788
|
+
if (bare.length > 1) {
|
|
789
|
+
return {
|
|
790
|
+
rec: undefined,
|
|
791
|
+
error: `${JSON.stringify(dzIdOrText)} is a bare id suffix shared by ${bare.length} learned patterns (${bare.map((r) => r.id).join(', ')}) — pass the full id`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const only = bare[0];
|
|
795
|
+
if (only !== undefined) return { rec: only, matchedBy: 'suffix' };
|
|
796
|
+
return { rec: undefined, error: `no learned pattern matches ${JSON.stringify(dzIdOrText)}` };
|
|
797
|
+
}
|
|
798
|
+
|
|
758
799
|
async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, opts: { reward?: number; ts?: string; mergedFrom?: readonly string[]; exposure?: boolean }): Promise<ReinforcePatternResult> {
|
|
759
800
|
const records = loadStoreRecords(projectRoot);
|
|
760
|
-
const
|
|
761
|
-
if (rec === undefined) return { ok: false, error:
|
|
801
|
+
const resolved = resolveReinforceTarget(records, dzIdOrText);
|
|
802
|
+
if (resolved.rec === undefined) return { ok: false, error: resolved.error };
|
|
803
|
+
const rec = resolved.rec;
|
|
762
804
|
const pairId = typeof rec.metadata?.['lessonPairId'] === 'string' ? rec.metadata['lessonPairId'] : undefined;
|
|
763
805
|
const targets = pairId === undefined
|
|
764
806
|
? [rec]
|
|
@@ -781,14 +823,14 @@ async function reinforcePatternLocked(projectRoot: string, dzIdOrText: string, o
|
|
|
781
823
|
for (const target of targets) {
|
|
782
824
|
const promotedMeta = { ...(target.metadata ?? {}), ...encodeReinforcementState(nextState) };
|
|
783
825
|
if (opts.exposure !== true) {
|
|
784
|
-
|
|
785
|
-
|
|
826
|
+
promotedMeta['qStatus'] = 'promoted';
|
|
827
|
+
promotedMeta['promotedAt'] = ts;
|
|
786
828
|
}
|
|
787
829
|
const put = await putStoreRecord(projectRoot, { ...target, metadata: promotedMeta });
|
|
788
830
|
if ('error' in put) return { ok: false, dzId: rec.id, error: put.error };
|
|
789
831
|
}
|
|
790
832
|
try {
|
|
791
|
-
appendFileSync(join(projectRoot, '.dz', 'sessions.jsonl'), JSON.stringify({ event: 'reinforce', ts, dzId: rec.id, uses: nextState.uses }) + '\n');
|
|
833
|
+
appendFileSync(join(projectRoot, '.dz', 'sessions.jsonl'), JSON.stringify({ event: 'reinforce', ts, dzId: rec.id, uses: nextState.uses, exposure: opts.exposure === true }) + '\n');
|
|
792
834
|
} catch { /* best-effort */ }
|
|
793
835
|
return { ok: true, dzId: rec.id, uses: nextState.uses, reward: observed };
|
|
794
836
|
}
|
|
@@ -845,10 +887,9 @@ export async function promotePatterns(projectRoot: string, dzIds: readonly strin
|
|
|
845
887
|
const targets = pairId === undefined ? [rec] : records.filter((r) => r.metadata?.['lessonPairId'] === pairId);
|
|
846
888
|
const pending = targets.filter((target) => !handled.has(target.id) && readQuarantineState(target).quarantined);
|
|
847
889
|
if (pending.length === 0) { notQuarantined.push(id); continue; }
|
|
890
|
+
const promotedAt = new Date().toISOString();
|
|
848
891
|
for (const target of pending) {
|
|
849
|
-
const meta = { ...(target.metadata ?? {}) };
|
|
850
|
-
delete meta['qStatus'];
|
|
851
|
-
delete meta['quarantinedAt'];
|
|
892
|
+
const meta = { ...(target.metadata ?? {}), qStatus: 'promoted', promotedAt };
|
|
852
893
|
const put = await putStoreRecord(projectRoot, { ...target, metadata: meta });
|
|
853
894
|
if ('error' in put) return { ok: false, promoted, notFound, notQuarantined, error: put.error };
|
|
854
895
|
handled.add(target.id);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Что считается ПУБЛИКУЕМЫМ ИСХОДНИКОМ для гейта review-round.
|
|
3
|
+
*
|
|
4
|
+
* ЗАЧЕМ ЭТО ВЫНЕСЕНО. Правило `review-round` — единственный жёсткий гейт, спрашивающий, читал ли
|
|
5
|
+
* изменённый код кто-то, кроме автора. Его сборщик фактов жил одной строкой в `cli.ts` и помечал
|
|
6
|
+
* изменение исходником только по пути `^(src|lib|bin|skills)/`.
|
|
7
|
+
*
|
|
8
|
+
* ИЗМЕРЕНО (запись 5be1bee1, релиз 2026-08-25): пак навыков возит своё содержимое из `templates/`
|
|
9
|
+
* и `<имя-навыка>/`, поэтому `skills-feature-adr` мог поднять версию, изменить сам конвейер
|
|
10
|
+
* `templates/.claude/workflows/feature-adr.js` и гейт `feature-adr/scripts/*.mjs` — и правило НЕ
|
|
11
|
+
* СРАБАТЫВАЛО, а гейт печатал PASS. То есть пакеты, несущие ВЕСЬ конвейер, лежали вне
|
|
12
|
+
* единственной проверки на прочитанность. Страж, который не может сработать, хуже отсутствующего:
|
|
13
|
+
* он создаёт видимость защиты.
|
|
14
|
+
*
|
|
15
|
+
* ПОЧЕМУ НЕ «ВСЁ, ЧТО ПУБЛИКУЕТСЯ». Комментарий прежней строки защищал осознанное решение:
|
|
16
|
+
* «SOURCE = то, что ОТГРУЖАЕТСЯ и может ОШИБИТЬСЯ В РАНТАЙМЕ. Тесты, документация и фикстуры
|
|
17
|
+
* исключены… расширение области — это то, из-за чего жёсткий гейт выключают». Взять список
|
|
18
|
+
* публикуемых файлов целиком значило бы затянуть в гейт всю документацию пака и вернуть ровно ту
|
|
19
|
+
* переусердствующую форму, от которой это решение защищает.
|
|
20
|
+
*
|
|
21
|
+
* ПОЭТОМУ КРИТЕРИЙ — ИСПОЛНИМОСТЬ, А НЕ РАСПОЛОЖЕНИЕ: файл считается исходником, если он либо
|
|
22
|
+
* лежит в одном из исторических каталогов, либо ИСПОЛНЯЕТСЯ у потребителя по своему расширению —
|
|
23
|
+
* где бы он ни лежал. Документация, данные и тесты исключены в обоих случаях.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Каталоги, чьё содержимое отгружается, но исходником для этого гейта не считается. */
|
|
27
|
+
const NOT_SOURCE_DIR = /(^|\/)(test|tests|__tests__|fixtures|__fixtures__|dist|node_modules|coverage)(\/|$)/;
|
|
28
|
+
|
|
29
|
+
/** Исторические каталоги исходников — сохранены дословно, чтобы поведение не поехало. */
|
|
30
|
+
const LEGACY_SOURCE_DIR = /^(src|lib|bin|skills)\//;
|
|
31
|
+
|
|
32
|
+
/** Расширения, которые исполняются у потребителя. Разметка и данные сюда НЕ входят. */
|
|
33
|
+
const EXECUTABLE_EXT = /\.(?:[cm]?js|[cm]?ts|sh|bash|py)$/;
|
|
34
|
+
|
|
35
|
+
/** Расширения, которые исторически исключались даже внутри каталогов исходников. */
|
|
36
|
+
const NEVER_SOURCE_EXT = /\.(?:md|json|txt|yaml|yml)$/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Изменение этого файла означает, что пакет отгружает НОВЫЙ ИСПОЛНЯЕМЫЙ код?
|
|
40
|
+
*
|
|
41
|
+
* @param relPath путь ВНУТРИ каталога пакета (`templates/.claude/workflows/feature-adr.js`),
|
|
42
|
+
* а не от корня репозитория
|
|
43
|
+
*/
|
|
44
|
+
export function isShippedSource(relPath: string): boolean {
|
|
45
|
+
if (typeof relPath !== 'string' || relPath === '') return false;
|
|
46
|
+
const p = relPath.replace(/\\/g, '/');
|
|
47
|
+
// Порядок важен: исключение каталога сильнее любого включения, иначе `test/helper.ts`
|
|
48
|
+
// и пересобранный `dist/index.js` начали бы требовать круг ревью.
|
|
49
|
+
if (NOT_SOURCE_DIR.test(p)) return false;
|
|
50
|
+
if (NEVER_SOURCE_EXT.test(p)) return false;
|
|
51
|
+
if (LEGACY_SOURCE_DIR.test(p)) return true;
|
|
52
|
+
return EXECUTABLE_EXT.test(p);
|
|
53
|
+
}
|
package/src/publish.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* @packageDocumentation
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import { maskMarkdown } from './markdown-masker.js';
|
|
8
9
|
import { existsSync, readFileSync, writeFileSync, readdirSync, renameSync } from 'node:fs';
|
|
9
|
-
import { join as pathJoin, relative as pathRelative, resolve as pathResolve } from 'node:path';
|
|
10
|
+
import { join as pathJoin, relative as pathRelative, resolve as pathResolve, isAbsolute as pathIsAbsolute, sep as pathSep } from 'node:path';
|
|
10
11
|
|
|
11
12
|
import { decidePublishSigning, decidePostSigningVerification } from './publish-signing.js';
|
|
12
13
|
import { join } from 'node:path';
|
|
@@ -381,6 +382,24 @@ export function publishArgv(mode: ProvenanceMode, env: NodeJS.ProcessEnv): strin
|
|
|
381
382
|
return decideProvenance(mode, env).useProvenance ? base + ' --provenance' : base;
|
|
382
383
|
}
|
|
383
384
|
|
|
385
|
+
/** Match substrings against package identity and path without including the checkout root. */
|
|
386
|
+
export function matchesPublishFilter(
|
|
387
|
+
pkg: { name: string; dir: string },
|
|
388
|
+
filter: string,
|
|
389
|
+
monorepoRoot: string,
|
|
390
|
+
): boolean {
|
|
391
|
+
const normalizedFilter = filter.replace(/\\/g, '/');
|
|
392
|
+
const relativeDir = pathRelative(pathResolve(monorepoRoot), pathResolve(pkg.dir));
|
|
393
|
+
// A foreign path must not reintroduce checkout ancestors into the match.
|
|
394
|
+
const outsideRoot = relativeDir === '..' || relativeDir.startsWith(`..${pathSep}`) || pathIsAbsolute(relativeDir);
|
|
395
|
+
// Only Windows spells separators with a backslash. On POSIX a backslash is a legal character
|
|
396
|
+
// INSIDE a directory name, so rewriting it here would invent a separator the filesystem does
|
|
397
|
+
// not have and over-select that package — the same class as the root-substring defect.
|
|
398
|
+
const comparableDir = pathSep === '\\' ? relativeDir.replace(/\\/g, '/') : relativeDir;
|
|
399
|
+
return pkg.name.includes(normalizedFilter)
|
|
400
|
+
|| (!outsideRoot && comparableDir.includes(normalizedFilter));
|
|
401
|
+
}
|
|
402
|
+
|
|
384
403
|
/** Discover all publishable @dzhechkov packages. */
|
|
385
404
|
export function discoverPackages(monorepoRoot: string): { name: string; dir: string; version: string }[] {
|
|
386
405
|
const baseDir = join(monorepoRoot, 'packages', '@dzhechkov');
|
|
@@ -741,21 +760,20 @@ const REGION_END = /^ {0,3}#{1,2}\s/;
|
|
|
741
760
|
|
|
742
761
|
/** Blank out fenced-code lines while PRESERVING line numbering, so a `## Status` or an entry-shaped
|
|
743
762
|
* line inside an example neither starts nor ends the region (the same fence blindness was found in
|
|
744
|
-
* another checker on the same day).
|
|
763
|
+
* another checker on the same day).
|
|
764
|
+
*
|
|
765
|
+
* Scoping is DELEGATED to the canonical masker, not re-implemented. The local version this replaces
|
|
766
|
+
* tracked only the marker CHARACTER and closed on any run of three or more, ignoring CommonMark's
|
|
767
|
+
* rule that a closing fence may not be SHORTER than the opening one. MEASURED 2026-09-20: in a
|
|
768
|
+
* README whose entry body quotes a markdown example as ````markdown … ```` , the inner ``` closed
|
|
769
|
+
* the outer block early, the quoted `## Status` became visible, the region ENDED there, and the
|
|
770
|
+
* body line below — `В `1.2.2` эта строка тела …` — lost protection. That is exactly the
|
|
771
|
+
* 2026-08-25 incident this function exists to stop: a record's body relabelled forward and shipped
|
|
772
|
+
* to npm. `unclosed: 'hide'` preserves the local version's policy — an unclosed opener masks to
|
|
773
|
+
* end of file, which is also the CommonMark reading.
|
|
774
|
+
*/
|
|
745
775
|
function maskFences(lines: readonly string[]): string[] {
|
|
746
|
-
|
|
747
|
-
let fence: string | null = null;
|
|
748
|
-
for (const line of lines) {
|
|
749
|
-
const open = /^ {0,3}(```+|~~~+)/.exec(line);
|
|
750
|
-
if (fence === null && open !== null && open[1] !== undefined) { fence = open[1][0] as string; out.push(''); continue; }
|
|
751
|
-
if (fence !== null) {
|
|
752
|
-
out.push('');
|
|
753
|
-
if (new RegExp('^ {0,3}' + fence + '{3,}\\s*$').test(line)) fence = null;
|
|
754
|
-
continue;
|
|
755
|
-
}
|
|
756
|
-
out.push(line);
|
|
757
|
-
}
|
|
758
|
-
return out;
|
|
776
|
+
return String(maskMarkdown(lines.join('\n'), { unclosed: 'hide' })).split('\n');
|
|
759
777
|
}
|
|
760
778
|
|
|
761
779
|
/**
|
|
@@ -883,10 +901,14 @@ export function publishPackages(
|
|
|
883
901
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
884
902
|
});
|
|
885
903
|
|
|
904
|
+
if (opts.filter?.some((filter) => filter.length === 0)) {
|
|
905
|
+
throw new Error('publish: --filter requires non-empty package-name substrings (empty would match ALL packages)');
|
|
906
|
+
}
|
|
907
|
+
|
|
886
908
|
const packages = discoverPackages(monorepoRoot);
|
|
887
909
|
const results: PublishResult[] = [];
|
|
888
910
|
const filtered = opts.filter && opts.filter.length > 0
|
|
889
|
-
? packages.filter((p) => opts.filter!.some((f) => p
|
|
911
|
+
? packages.filter((p) => opts.filter!.some((f) => matchesPublishFilter(p, f, monorepoRoot)))
|
|
890
912
|
: packages;
|
|
891
913
|
// Publish dependencies before dependents so pnpm rewrites workspace:* to the
|
|
892
914
|
// freshly-bumped version, never a stale one (the harness-cli@0.3.122 breakage).
|