@dzhechkov/harness-core 0.8.45 → 0.8.47
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 +206 -174
- package/README.md +136 -8
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +32 -2
- package/dist/agentdb-index.js.map +1 -1
- package/dist/agentdb-snapshot.d.ts +14 -1
- package/dist/agentdb-snapshot.d.ts.map +1 -1
- package/dist/agentdb-snapshot.js +100 -4
- package/dist/agentdb-snapshot.js.map +1 -1
- package/dist/apply-leg.d.ts +9 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +47 -10
- package/dist/apply-leg.js.map +1 -1
- package/dist/architecture.d.ts +0 -5
- package/dist/architecture.d.ts.map +1 -1
- package/dist/architecture.js +25 -2
- package/dist/architecture.js.map +1 -1
- package/dist/codex-rollouts.d.ts +35 -7
- package/dist/codex-rollouts.d.ts.map +1 -1
- package/dist/codex-rollouts.js +201 -113
- package/dist/codex-rollouts.js.map +1 -1
- package/dist/cost-ledger.d.ts +42 -3
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +478 -15
- package/dist/cost-ledger.js.map +1 -1
- package/dist/feature-adr-routing.d.ts +16 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +21 -0
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/guard.d.ts +7 -0
- package/dist/guard.d.ts.map +1 -1
- package/dist/guard.js +48 -0
- package/dist/guard.js.map +1 -1
- package/dist/index.d.ts +13 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-plan.d.ts +4 -0
- package/dist/loop-plan.d.ts.map +1 -1
- package/dist/loop-plan.js +4 -0
- package/dist/loop-plan.js.map +1 -1
- package/dist/mutation-gate.d.ts +36 -1
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +109 -19
- package/dist/mutation-gate.js.map +1 -1
- package/dist/npm-homepage.d.ts +64 -0
- package/dist/npm-homepage.d.ts.map +1 -0
- package/dist/npm-homepage.js +109 -0
- package/dist/npm-homepage.js.map +1 -0
- package/dist/operations.d.ts +6 -0
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +150 -24
- package/dist/operations.js.map +1 -1
- package/dist/pack-inventory.d.ts +13 -0
- package/dist/pack-inventory.d.ts.map +1 -1
- package/dist/pack-inventory.js +28 -2
- package/dist/pack-inventory.js.map +1 -1
- package/dist/parity.d.ts.map +1 -1
- package/dist/parity.js +4 -1
- package/dist/parity.js.map +1 -1
- package/dist/publish-sibling-drift.d.ts +43 -1
- package/dist/publish-sibling-drift.d.ts.map +1 -1
- package/dist/publish-sibling-drift.js +269 -13
- package/dist/publish-sibling-drift.js.map +1 -1
- package/dist/publish.d.ts +5 -1
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +18 -1
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +63 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +82 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release-package-audit.d.ts +86 -0
- package/dist/release-package-audit.d.ts.map +1 -0
- package/dist/release-package-audit.js +272 -0
- package/dist/release-package-audit.js.map +1 -0
- package/dist/release.d.ts +8 -1
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +58 -32
- package/dist/release.js.map +1 -1
- package/dist/round-exec-claim.d.ts +36 -0
- package/dist/round-exec-claim.d.ts.map +1 -0
- package/dist/round-exec-claim.js +17 -0
- package/dist/round-exec-claim.js.map +1 -0
- package/dist/round.d.ts +10 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +5 -1
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +11 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +124 -20
- package/dist/run-records.js.map +1 -1
- package/dist/session-retro.d.ts +33 -0
- package/dist/session-retro.d.ts.map +1 -1
- package/dist/session-retro.js +170 -34
- package/dist/session-retro.js.map +1 -1
- package/dist/sign.d.ts +44 -0
- package/dist/sign.d.ts.map +1 -1
- package/dist/sign.js +126 -1
- package/dist/sign.js.map +1 -1
- package/dist/stage-usage.d.ts +148 -0
- package/dist/stage-usage.d.ts.map +1 -0
- package/dist/stage-usage.js +261 -0
- package/dist/stage-usage.js.map +1 -0
- package/dist/statusline.d.ts +23 -0
- package/dist/statusline.d.ts.map +1 -1
- package/dist/statusline.js +167 -1
- package/dist/statusline.js.map +1 -1
- package/dist/workflow-run-dispatch.d.ts +21 -19
- package/dist/workflow-run-dispatch.d.ts.map +1 -1
- package/dist/workflow-run-dispatch.js +206 -95
- package/dist/workflow-run-dispatch.js.map +1 -1
- package/dist/workflow-run.d.ts +12 -3
- package/dist/workflow-run.d.ts.map +1 -1
- package/dist/workflow-run.js +29 -2
- package/dist/workflow-run.js.map +1 -1
- package/package.json +4 -4
- package/sbom.json +309 -229
- package/src/agentdb-index.ts +31 -2
- package/src/agentdb-snapshot.ts +105 -4
- package/src/apply-leg.ts +47 -10
- package/src/architecture.ts +15 -2
- package/src/codex-rollouts.ts +155 -147
- package/src/cost-ledger.ts +308 -19
- package/src/feature-adr-routing.ts +22 -0
- package/src/guard.ts +52 -0
- package/src/index.ts +19 -3
- package/src/loop-plan.ts +8 -0
- package/src/mutation-gate.ts +153 -19
- package/src/npm-homepage.ts +142 -0
- package/src/operations.ts +177 -22
- package/src/pack-inventory.ts +27 -2
- package/src/parity.ts +4 -1
- package/src/publish-sibling-drift.ts +214 -11
- package/src/publish.ts +20 -1
- package/src/qe-bridge.ts +86 -1
- package/src/release-package-audit.ts +264 -0
- package/src/release.ts +55 -22
- package/src/round-exec-claim.ts +43 -0
- package/src/round.ts +8 -0
- package/src/run-records.ts +101 -22
- package/src/session-retro.ts +153 -31
- package/src/sign.ts +136 -1
- package/src/stage-usage.ts +191 -0
- package/src/statusline.ts +149 -1
- package/src/workflow-run-dispatch.ts +148 -89
- package/src/workflow-run.ts +40 -4
- package/dist/ledger-cost-fill.d.ts +0 -58
- package/dist/ledger-cost-fill.d.ts.map +0 -1
- package/dist/ledger-cost-fill.js +0 -78
- package/dist/ledger-cost-fill.js.map +0 -1
- package/dist/retro.d.ts +0 -131
- package/dist/retro.d.ts.map +0 -1
- package/dist/retro.js +0 -207
- package/dist/retro.js.map +0 -1
- package/dist/sbom.d.ts +0 -42
- package/dist/sbom.d.ts.map +0 -1
- package/dist/sbom.js +0 -120
- package/dist/sbom.js.map +0 -1
package/src/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export { REPOSITORY_ORIGIN } from './repository-origin.js';
|
|
|
19
19
|
export { noSearchableTermsReason } from '@dzhechkov/memory';
|
|
20
20
|
|
|
21
21
|
export * from './skills.js';
|
|
22
|
+
export { buildStageUsageReport } from './stage-usage.js';
|
|
23
|
+
export { deriveStageUsageReport, renderStageUsageReport } from './cost-ledger.js';
|
|
22
24
|
export * from './apply.js';
|
|
23
25
|
export {
|
|
24
26
|
buildPublicSnapshot,
|
|
@@ -175,6 +177,8 @@ export {
|
|
|
175
177
|
parseBridgeOutput,
|
|
176
178
|
buildBridgeFailureRecord,
|
|
177
179
|
renderBridgeReport,
|
|
180
|
+
resolveBridgeRoundIdentity,
|
|
181
|
+
selectQeBridgeRoundSignoff,
|
|
178
182
|
} from './qe-bridge.js';
|
|
179
183
|
export type { BridgeFamily, BridgeFailureReason, BridgeFinding, BridgeSignoff, BridgeParse, BridgeParseOk, BridgeParseFail, BridgeChannels, BridgeAudit, ClaudeResultExtraction, NamedExtract, BridgePromptInput } from './qe-bridge.js';
|
|
180
184
|
export type { PatternRecord, SessionRecord, LearningConfig, MemoryLearningConfig, LoadOptions, ConsolidateResult, ConsolidateOptions, SqliteBackendMode, RecallHit, RecallPatternsOptions, RecordLessonFormsResult, SessionsSource, PruneNoiseResult, RemovePatternsResult, SnapshotStoreResult, ReinforcementState, ReinforcePatternResult, StoreStats, LessonDeltaReport, LessonDeltaRow, QuarantineState, PromoteResult, QuarantineExpiryCandidate } from './patterns.js';
|
|
@@ -325,8 +329,8 @@ export type {
|
|
|
325
329
|
StoreGuardPrunePlan,
|
|
326
330
|
StoreGuardPruneDeps,
|
|
327
331
|
} from './store-guard-prune.js';
|
|
328
|
-
export { statuslineData, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath, writeFeatureAdrStateDetailed, renderFeatureAdrPhaseLine } from './statusline.js';
|
|
329
|
-
export type { StatuslineData, StatuslineStoreHealth, FeatureAdrState, WriteFeatureAdrStateInput, WriteFeatureAdrStateResult } from './statusline.js';
|
|
332
|
+
export { statuslineData, readFeatureAdrObservation, readFeatureAdrState, writeFeatureAdrState, featureAdrStateDir, featureAdrStatePath, writeFeatureAdrStateDetailed, renderFeatureAdrPhaseLine } from './statusline.js';
|
|
333
|
+
export type { StatuslineData, StatuslineStoreHealth, FeatureAdrObservation, FeatureAdrSelector, FeatureAdrState, WriteFeatureAdrStateInput, WriteFeatureAdrStateResult } from './statusline.js';
|
|
330
334
|
export {
|
|
331
335
|
ETA_MAX_STAGE_MS,
|
|
332
336
|
estimateEta,
|
|
@@ -782,7 +786,7 @@ export type {
|
|
|
782
786
|
} from './event-chain.js';
|
|
783
787
|
export { packArtifact, readWorkspaceVersions, UnknownWorkspaceSpecError } from './pack-artifact.js';
|
|
784
788
|
export type { PackArtifactResult, ExecLike } from './pack-artifact.js';
|
|
785
|
-
export { decideProvenance, environmentCanMintProvenance, publishArgv, matchesPublishFilter, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
|
|
789
|
+
export { decideProvenance, environmentCanMintProvenance, publishArgv, matchesPublishFilter, validatePublishFilters, discoverPackages, publishPackages, bumpPatch, compareVersions, findUnpackagedSkills, findUnpublishedWorkspaceFloors, rewriteWorkspaceSpecs, orderByDependencies, syncReadmeVersion, isChangelogEntryLine, changelogRegion, planReadmeVersionSync } from './publish.js';
|
|
786
790
|
export type { ReadmeVersionSyncPlan, ReadmeSyncRewrite } from './publish.js';
|
|
787
791
|
export { RELEASE_LINE_RE, parseReleaseLine, findReleaseLine, rewriteReleaseLine, shortPackageName } from './release-line.js';
|
|
788
792
|
export type { ReleaseLineToken, ReleaseLineParse, ReleaseLineMatch } from './release-line.js';
|
|
@@ -958,6 +962,8 @@ export {
|
|
|
958
962
|
PLAN_GATE_SCRIPT,
|
|
959
963
|
planCompletenessGateCmd,
|
|
960
964
|
parsePlanGateVerdict,
|
|
965
|
+
// landed-barrier-anchored-line: the design/plan/plan-repair landing-barrier reply parser.
|
|
966
|
+
parseLandedProbe,
|
|
961
967
|
// p16-non-js-portability: the gate-script search chain's operator note (ADR-002/AM-7) and the
|
|
962
968
|
// dzBin absolutization (ADR-003). Named for the same reason as the three above.
|
|
963
969
|
refusalNoteFor,
|
|
@@ -1036,6 +1042,7 @@ export type {
|
|
|
1036
1042
|
ParsedBaselineCapture,
|
|
1037
1043
|
ParsedLandingSignal,
|
|
1038
1044
|
PlanGateVerdict,
|
|
1045
|
+
LandedProbeVerdict,
|
|
1039
1046
|
PlanSnapshot,
|
|
1040
1047
|
PlanGateCmdOpts,
|
|
1041
1048
|
CodexReviewCommandInput,
|
|
@@ -1156,6 +1163,8 @@ export * from './bto-optimize.js';
|
|
|
1156
1163
|
export * from './discrimination-gate.js';
|
|
1157
1164
|
export * from './guard.js';
|
|
1158
1165
|
export * from './guard-volume.js';
|
|
1166
|
+
// npm-homepage-guard (backlog e5d0d383): pure facts + fs reader for the `npm-homepage` guard rule.
|
|
1167
|
+
export * from './npm-homepage.js';
|
|
1159
1168
|
// Root AGENTS.md policy projection: pure anchor extraction/render/hash drift +
|
|
1160
1169
|
// the shared-emitter I/O entry point exported from operations.
|
|
1161
1170
|
export * from './agents-policy.js';
|
|
@@ -1368,6 +1377,10 @@ export { parseQeBridgeStdoutCost } from './review-cost.js';
|
|
|
1368
1377
|
export type { QeBridgeCost, QeBridgeCostTokens } from './review-cost.js';
|
|
1369
1378
|
export { parseCodexTokens, classifyRoundExecOutcome, buildRoundExecRow } from './round-exec.js';
|
|
1370
1379
|
export type { RoundExecOutcome, RoundExecLedgerRow } from './round-exec.js';
|
|
1380
|
+
// round-exec-claim-takeover FR-1: the pure verdict `dz round exec` consults before refusing a
|
|
1381
|
+
// standing exec claim (held / stale-dead / unknown); the pid probe itself stays in run-registry.
|
|
1382
|
+
export { decideExecClaimTakeover } from './round-exec-claim.js';
|
|
1383
|
+
export type { ExecClaimVerdict, ExecClaimTakeoverInput } from './round-exec-claim.js';
|
|
1371
1384
|
export * from './run-cleanup.js';
|
|
1372
1385
|
|
|
1373
1386
|
export * from './cross-family-control.js';
|
|
@@ -1380,3 +1393,6 @@ export { decideTestReceipt, renderTestReceiptVerdict } from './test-receipt.js';
|
|
|
1380
1393
|
export type { TestReceipt, TestReceiptVerdict, TestReceiptScopeState } from './test-receipt.js';
|
|
1381
1394
|
export { decideBackupFreshness, renderBackupFreshness } from './backup-freshness.js';
|
|
1382
1395
|
export type { BackupFreshnessVerdict } from './backup-freshness.js';
|
|
1396
|
+
|
|
1397
|
+
export { planReleasePackageAudit, judgeReleasePackageAudit, judgeReleaseCohortAudit } from './release-package-audit.js';
|
|
1398
|
+
export type { ReleasePackageAuditPlan, ReleasePackageAuditResult, ReleaseWorkspaceAuditReport } from './release-package-audit.js';
|
package/src/loop-plan.ts
CHANGED
|
@@ -1489,6 +1489,10 @@ export function toTraceProjection(plan: LoopPlan): TraceProjection {
|
|
|
1489
1489
|
/** One dispatching step as the scheduler needs it — everything it must know to build a prompt,
|
|
1490
1490
|
* pick a model, spend budget, retry, and verify a deliverable landed. */
|
|
1491
1491
|
export interface RunStepSpec {
|
|
1492
|
+
role?: string | null;
|
|
1493
|
+
tier?: string | null;
|
|
1494
|
+
mode?: string | null;
|
|
1495
|
+
estimate?: unknown;
|
|
1492
1496
|
stepId: string;
|
|
1493
1497
|
kind: 'agent' | 'gate';
|
|
1494
1498
|
phase: string;
|
|
@@ -1574,6 +1578,10 @@ export function toRunProjection(plan: LoopPlan): RunProjection {
|
|
|
1574
1578
|
stepId: s.stepId,
|
|
1575
1579
|
kind: s.kind === 'gate' ? 'gate' : 'agent',
|
|
1576
1580
|
phase: s.phase,
|
|
1581
|
+
role: typeof s['x-role'] === 'string' ? s['x-role'] : null,
|
|
1582
|
+
tier: typeof s['x-tier'] === 'string' ? s['x-tier'] : null,
|
|
1583
|
+
mode: typeof s['x-mode'] === 'string' ? s['x-mode'] : null,
|
|
1584
|
+
estimate: s['x-estimate'] ?? null,
|
|
1577
1585
|
prompt: typeof s.prompt === 'string' ? s.prompt : null,
|
|
1578
1586
|
model: typeof s.model === 'string' && s.model !== '' ? s.model : null,
|
|
1579
1587
|
deliverable: s.deliverable ?? 'return-value',
|
package/src/mutation-gate.ts
CHANGED
|
@@ -121,6 +121,51 @@ export interface InjectVitestWorkerCeilingResult {
|
|
|
121
121
|
/** segments where `vitest run` was found only by the LOOSE token-pair fallback (command position
|
|
122
122
|
* unrecognised) — the CLI reports these so an odd wrapper shape is visible, not silent. */
|
|
123
123
|
readonly looseSegments: number;
|
|
124
|
+
/**
|
|
125
|
+
* vitest-ceiling-injection-hardening FR-1: set when the command cannot be split with confidence —
|
|
126
|
+
* an UNTERMINATED quote leaves both the segment boundaries and the argument ends to guesswork (a
|
|
127
|
+
* swallowed `&&` would move a boundary). The command is then returned byte-identical, all counts
|
|
128
|
+
* are 0, and the reason is named here — never a best-effort injection. Absent on the normal path.
|
|
129
|
+
*/
|
|
130
|
+
readonly notEstablished?: VitestInjectionNotEstablished;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** vitest-ceiling-injection-hardening FR-1: why the injection was not attempted. */
|
|
134
|
+
export interface VitestInjectionNotEstablished {
|
|
135
|
+
readonly reason: 'unterminated-single-quote' | 'unterminated-double-quote' | 'unsupported-shell-construct';
|
|
136
|
+
/**
|
|
137
|
+
* set only for `unsupported-shell-construct` (astra r1, MAJOR 1): the construct the scanner does NOT
|
|
138
|
+
* model — reading the quotes inside it as ordinary quotes gave valid commands a false
|
|
139
|
+
* `unterminated-*` reason. Deliberately a refusal, not a parser: heuristic shell parsing does not converge.
|
|
140
|
+
*/
|
|
141
|
+
readonly construct?: UnsupportedShellConstruct;
|
|
142
|
+
/** human-readable, names the quote kind / construct and the raw offset where it starts. */
|
|
143
|
+
readonly message: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** shell constructs `splitUnquotedSegments` detects and REFUSES rather than models. */
|
|
147
|
+
export type UnsupportedShellConstruct =
|
|
148
|
+
| 'comment' // unquoted `#` at a word start
|
|
149
|
+
| 'heredoc' // unquoted `<<` (also `<<<`)
|
|
150
|
+
| 'command-substitution' // `$(` unquoted or inside double quotes
|
|
151
|
+
| 'backtick-substitution' // a backtick unquoted or inside double quotes
|
|
152
|
+
| 'parameter-expansion' // `${` unquoted or inside double quotes
|
|
153
|
+
| 'ansi-c-quoting'; // unquoted `$'`
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* vitest-ceiling-injection-hardening FR-2: the backslash policy. The gate spawns the command with
|
|
157
|
+
* `shell: true` (`/bin/sh` on POSIX, `cmd.exe` on win32), and the two disagree on `\`: `/bin/sh`
|
|
158
|
+
* treats it as an escape (so `C:\proj\vitest` unquoted really becomes `C:projvitest` and is not
|
|
159
|
+
* vitest), `cmd.exe` does not. `win32` here means ONLY: backslash is a literal path character.
|
|
160
|
+
* cmd.exe quoting (`^` escapes, no single-quote quoting) is NOT modelled — quotes, operators and the
|
|
161
|
+
* unsupported-construct refusals stay POSIX-shaped on both (astra r1, MAJOR 2). Core never reads
|
|
162
|
+
* `process.platform` (core-boundary rule A), so the caller passes it; the default is `posix`.
|
|
163
|
+
*/
|
|
164
|
+
export type VitestInjectionPlatform = 'posix' | 'win32';
|
|
165
|
+
|
|
166
|
+
export interface InjectVitestWorkerCeilingOptions {
|
|
167
|
+
/** default `'posix'` — HEAD behaviour. `'win32'`: backslash is a literal path character (never an escape, no line continuation); cmd.exe quoting (`^` escapes, no single-quote quoting) is NOT modelled. */
|
|
168
|
+
readonly platform?: VitestInjectionPlatform;
|
|
124
169
|
}
|
|
125
170
|
|
|
126
171
|
interface RawSegment {
|
|
@@ -129,17 +174,54 @@ interface RawSegment {
|
|
|
129
174
|
readonly terminator: string;
|
|
130
175
|
}
|
|
131
176
|
|
|
177
|
+
interface SplitResult {
|
|
178
|
+
readonly segments: RawSegment[];
|
|
179
|
+
/** the quote left open at the end of the command, with the raw offset that opened it; null when all closed. */
|
|
180
|
+
readonly unterminated: { readonly quote: "'" | '"'; readonly offset: number } | null;
|
|
181
|
+
/** the first construct the scanner does not model, with its raw offset; null when none. Checked before `unterminated`. */
|
|
182
|
+
readonly unsupported: { readonly construct: UnsupportedShellConstruct; readonly offset: number } | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** `$(` / `${` / backtick — active both unquoted and inside double quotes. */
|
|
186
|
+
function substitutionAt(cmd: string, i: number): UnsupportedShellConstruct | null {
|
|
187
|
+
const ch = cmd[i];
|
|
188
|
+
if (ch === '`') return 'backtick-substitution';
|
|
189
|
+
if (ch === '$' && cmd[i + 1] === '(') return 'command-substitution';
|
|
190
|
+
if (ch === '$' && cmd[i + 1] === '{') return 'parameter-expansion';
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
132
194
|
/**
|
|
133
195
|
* Split on unquoted `&&`/`||`/`;`/`|`, preserving each segment's own text (incl. surrounding
|
|
134
196
|
* whitespace). POSIX-escape aware (fix-round 1, F1): outside quotes `\` makes the next character
|
|
135
197
|
* literal (so it can neither open a quote nor start an operator); inside double quotes `\"` and `\\`
|
|
136
198
|
* are recognised escapes that do NOT close the string; inside single quotes nothing is escaped.
|
|
199
|
+
* With `backslashEscapes` false (win32, vitest-ceiling-injection-hardening FR-2) backslash is a
|
|
200
|
+
* literal path character everywhere; cmd.exe quoting (`^` escapes, no single-quote quoting) is NOT
|
|
201
|
+
* modelled. A quote still open at the end is REPORTED, never guessed past (FR-1); a construct the
|
|
202
|
+
* scanner does not model (comment, heredoc, `$(`, backtick, `${`, `$'`) is REFUSED before that (astra r1).
|
|
137
203
|
*/
|
|
138
|
-
function splitUnquotedSegments(cmd: string):
|
|
204
|
+
function splitUnquotedSegments(cmd: string, backslashEscapes: boolean): SplitResult {
|
|
139
205
|
const segments: RawSegment[] = [];
|
|
140
206
|
let segStart = 0;
|
|
141
207
|
let inSingle = false;
|
|
142
208
|
let inDouble = false;
|
|
209
|
+
let quoteOpenedAt = -1;
|
|
210
|
+
// astra r2: raw indices of characters made literal by an unquoted POSIX `\` — an escaped space
|
|
211
|
+
// continues the word, so a `#` after it is NOT at a word start (`issue\ #123` is one word).
|
|
212
|
+
const escapedAt = new Set<number>();
|
|
213
|
+
/** true when position `at` starts a shell word: string start, or an UNESCAPED whitespace/operator
|
|
214
|
+
* before it; a `\<newline>` continuation is removed by the shell, so look past it. */
|
|
215
|
+
const atWordStart = (at: number): boolean => {
|
|
216
|
+
let prev = at - 1;
|
|
217
|
+
for (;;) {
|
|
218
|
+
if (prev < 0) return true;
|
|
219
|
+
if (escapedAt.has(prev) && cmd[prev] === '\n') { prev -= 2; continue; } // `\<LF>` continuation
|
|
220
|
+
if (escapedAt.has(prev) && cmd[prev] === '\r' && cmd[prev + 1] === '\n') { prev -= 2; continue; }
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
return !escapedAt.has(prev) && /[\s;&|()<>]/u.test(cmd[prev]!);
|
|
224
|
+
};
|
|
143
225
|
let i = 0;
|
|
144
226
|
while (i < cmd.length) {
|
|
145
227
|
const ch = cmd[i];
|
|
@@ -150,32 +232,47 @@ function splitUnquotedSegments(cmd: string): RawSegment[] {
|
|
|
150
232
|
}
|
|
151
233
|
if (inDouble) {
|
|
152
234
|
if (ch === '"') { inDouble = false; i += 1; continue; }
|
|
153
|
-
if (ch === '\\') {
|
|
235
|
+
if (ch === '\\' && backslashEscapes) {
|
|
154
236
|
const next = cmd[i + 1];
|
|
155
|
-
// at least \" and \\ (F1's floor) — an escaped quote must not close the double-quoted span
|
|
156
|
-
|
|
237
|
+
// at least \" and \\ (F1's floor) — an escaped quote must not close the double-quoted span;
|
|
238
|
+
// \$ and \` are POSIX double-quote escapes too — an escaped `$(` is not a substitution.
|
|
239
|
+
if (next === '"' || next === '\\' || next === '$' || next === '`') { i += 2; continue; }
|
|
157
240
|
i += 1;
|
|
158
241
|
continue;
|
|
159
242
|
}
|
|
243
|
+
const inner = substitutionAt(cmd, i);
|
|
244
|
+
if (inner !== null) return { segments, unterminated: null, unsupported: { construct: inner, offset: i } };
|
|
160
245
|
i += 1;
|
|
161
246
|
continue;
|
|
162
247
|
}
|
|
248
|
+
if (ch === '\\' && backslashEscapes) {
|
|
249
|
+
// Outside any quote, POSIX makes the character AFTER `\` literal — skip both so it can never
|
|
250
|
+
// be mis-read as a quote-open, a construct or an operator boundary.
|
|
251
|
+
if (cmd[i + 1] !== undefined) escapedAt.add(i + 1);
|
|
252
|
+
if (cmd[i + 1] === '\r' && cmd[i + 2] === '\n') escapedAt.add(i + 2); // `\<CR><LF>` continuation
|
|
253
|
+
i += cmd[i + 1] === '\r' && cmd[i + 2] === '\n' ? 3 : cmd[i + 1] !== undefined ? 2 : 1;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
// astra r1, MAJOR 1: constructs the scanner does NOT model are refused BEFORE quote tracking can
|
|
257
|
+
// misread the quotes inside them (a comment's apostrophe, a heredoc body, a nested substitution).
|
|
258
|
+
const construct: UnsupportedShellConstruct | null = substitutionAt(cmd, i)
|
|
259
|
+
?? (ch === '$' && cmd[i + 1] === "'" ? 'ansi-c-quoting'
|
|
260
|
+
: ch === '<' && cmd[i + 1] === '<' ? 'heredoc'
|
|
261
|
+
: ch === '#' && atWordStart(i) ? 'comment'
|
|
262
|
+
: null);
|
|
263
|
+
if (construct !== null) return { segments, unterminated: null, unsupported: { construct, offset: i } };
|
|
163
264
|
if (ch === "'") {
|
|
164
265
|
inSingle = true;
|
|
266
|
+
quoteOpenedAt = i;
|
|
165
267
|
i += 1;
|
|
166
268
|
continue;
|
|
167
269
|
}
|
|
168
270
|
if (ch === '"') {
|
|
169
271
|
inDouble = true;
|
|
272
|
+
quoteOpenedAt = i;
|
|
170
273
|
i += 1;
|
|
171
274
|
continue;
|
|
172
275
|
}
|
|
173
|
-
if (ch === '\\') {
|
|
174
|
-
// Outside any quote, POSIX makes the character AFTER `\` literal — skip both so it can never
|
|
175
|
-
// be mis-read as a quote-open or an operator boundary.
|
|
176
|
-
i += cmd[i + 1] !== undefined ? 2 : 1;
|
|
177
|
-
continue;
|
|
178
|
-
}
|
|
179
276
|
const terminatorMatch = /^(&&|\|\||;|\|)/.exec(cmd.slice(i));
|
|
180
277
|
if (terminatorMatch) {
|
|
181
278
|
segments.push({ text: cmd.slice(segStart, i), terminator: terminatorMatch[0] });
|
|
@@ -186,7 +283,10 @@ function splitUnquotedSegments(cmd: string): RawSegment[] {
|
|
|
186
283
|
i += 1;
|
|
187
284
|
}
|
|
188
285
|
segments.push({ text: cmd.slice(segStart), terminator: '' });
|
|
189
|
-
|
|
286
|
+
const unterminated = inSingle ? { quote: "'" as const, offset: quoteOpenedAt }
|
|
287
|
+
: inDouble ? { quote: '"' as const, offset: quoteOpenedAt }
|
|
288
|
+
: null;
|
|
289
|
+
return { segments, unterminated, unsupported: null };
|
|
190
290
|
}
|
|
191
291
|
|
|
192
292
|
interface SegmentToken {
|
|
@@ -208,7 +308,7 @@ function isWhitespaceChar(ch: string | undefined): boolean {
|
|
|
208
308
|
* `<char>` outside any quote, single-quoted content kept verbatim) while `token.end` keeps the RAW
|
|
209
309
|
* source offset so `injectIntoSegment` can still splice into the ORIGINAL text unchanged elsewhere.
|
|
210
310
|
*/
|
|
211
|
-
function tokenizeSegment(text: string): SegmentToken[] {
|
|
311
|
+
function tokenizeSegment(text: string, backslashEscapes: boolean): SegmentToken[] {
|
|
212
312
|
const tokens: SegmentToken[] = [];
|
|
213
313
|
let i = 0;
|
|
214
314
|
while (i < text.length) {
|
|
@@ -229,7 +329,7 @@ function tokenizeSegment(text: string): SegmentToken[] {
|
|
|
229
329
|
}
|
|
230
330
|
if (inDouble) {
|
|
231
331
|
if (ch === '"') { inDouble = false; i += 1; continue; }
|
|
232
|
-
if (ch === '\\') {
|
|
332
|
+
if (ch === '\\' && backslashEscapes) {
|
|
233
333
|
const next = text[i + 1];
|
|
234
334
|
if (next === '"' || next === '\\') { value += next; i += 2; continue; }
|
|
235
335
|
// not one of the two claimed double-quote escapes: the backslash is literal (F1's floor).
|
|
@@ -243,7 +343,7 @@ function tokenizeSegment(text: string): SegmentToken[] {
|
|
|
243
343
|
}
|
|
244
344
|
if (ch === "'") { inSingle = true; i += 1; continue; }
|
|
245
345
|
if (ch === '"') { inDouble = true; i += 1; continue; }
|
|
246
|
-
if (ch === '\\') {
|
|
346
|
+
if (ch === '\\' && backslashEscapes) {
|
|
247
347
|
const next = text[i + 1];
|
|
248
348
|
// POSIX line continuation (lead delta after Codex r2, MEDIUM): `\<newline>` (and `\<CR><LF>`)
|
|
249
349
|
// is REMOVED by the shell, never a literal — a token must not swallow a newline as its value.
|
|
@@ -357,8 +457,8 @@ function findVitestRunLooseIndex(tokens: readonly SegmentToken[]): number | null
|
|
|
357
457
|
return null;
|
|
358
458
|
}
|
|
359
459
|
|
|
360
|
-
function injectIntoSegment(text: string, maxWorkers: number): { readonly text: string; readonly isVitest: boolean; readonly injected: boolean; readonly loose: boolean } {
|
|
361
|
-
const tokens = tokenizeSegment(text);
|
|
460
|
+
function injectIntoSegment(text: string, maxWorkers: number, backslashEscapes: boolean): { readonly text: string; readonly isVitest: boolean; readonly injected: boolean; readonly loose: boolean } {
|
|
461
|
+
const tokens = tokenizeSegment(text, backslashEscapes);
|
|
362
462
|
const strictIdx = findVitestRunCommandIndex(tokens);
|
|
363
463
|
const looseIdx = strictIdx === null ? findVitestRunLooseIndex(tokens) : null;
|
|
364
464
|
const runTokenIdx = strictIdx ?? looseIdx;
|
|
@@ -373,13 +473,47 @@ function injectIntoSegment(text: string, maxWorkers: number): { readonly text: s
|
|
|
373
473
|
return { text: injectedText, isVitest: true, injected: true, loose };
|
|
374
474
|
}
|
|
375
475
|
|
|
376
|
-
export function injectVitestWorkerCeiling(
|
|
377
|
-
|
|
476
|
+
export function injectVitestWorkerCeiling(
|
|
477
|
+
testCmd: string,
|
|
478
|
+
maxWorkers: number,
|
|
479
|
+
options: InjectVitestWorkerCeilingOptions = {},
|
|
480
|
+
): InjectVitestWorkerCeilingResult {
|
|
481
|
+
const backslashEscapes = options.platform !== 'win32';
|
|
482
|
+
const split = splitUnquotedSegments(testCmd, backslashEscapes);
|
|
483
|
+
if (split.unsupported !== null) {
|
|
484
|
+
const { construct, offset } = split.unsupported;
|
|
485
|
+
return {
|
|
486
|
+
cmd: testCmd,
|
|
487
|
+
vitestSegments: 0,
|
|
488
|
+
injected: 0,
|
|
489
|
+
looseSegments: 0,
|
|
490
|
+
notEstablished: {
|
|
491
|
+
reason: 'unsupported-shell-construct',
|
|
492
|
+
construct,
|
|
493
|
+
message: `unsupported shell construct '${construct}' at offset ${offset} — the scanner does not model it, so segment boundaries cannot be established; the command is left unchanged and --maxWorkers is NOT injected`,
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (split.unterminated !== null) {
|
|
498
|
+
const { quote, offset } = split.unterminated;
|
|
499
|
+
const kind = quote === '"' ? 'double' : 'single';
|
|
500
|
+
return {
|
|
501
|
+
cmd: testCmd,
|
|
502
|
+
vitestSegments: 0,
|
|
503
|
+
injected: 0,
|
|
504
|
+
looseSegments: 0,
|
|
505
|
+
notEstablished: {
|
|
506
|
+
reason: quote === '"' ? 'unterminated-double-quote' : 'unterminated-single-quote',
|
|
507
|
+
message: `unterminated ${kind} quote opened at offset ${offset} — segment boundaries cannot be established; the command is left unchanged and --maxWorkers is NOT injected`,
|
|
508
|
+
},
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
const segments = split.segments;
|
|
378
512
|
let vitestSegments = 0;
|
|
379
513
|
let injected = 0;
|
|
380
514
|
let looseSegments = 0;
|
|
381
515
|
const rebuilt = segments.map((segment) => {
|
|
382
|
-
const result = injectIntoSegment(segment.text, maxWorkers);
|
|
516
|
+
const result = injectIntoSegment(segment.text, maxWorkers, backslashEscapes);
|
|
383
517
|
if (result.isVitest) vitestSegments += 1;
|
|
384
518
|
if (result.injected) injected += 1;
|
|
385
519
|
if (result.loose) looseSegments += 1;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* npm-homepage — the facts behind the `dz guard` rule of the same name (backlog e5d0d383).
|
|
3
|
+
*
|
|
4
|
+
* Owner rule 2026-09-28 (`.claude/rules/npm-homepage.md`): every `packages/@dzhechkov/*` package, public or
|
|
5
|
+
* private, carries `homepage` = exactly `https://aicoding.space`. The GitHub links are NOT replaced — npm has
|
|
6
|
+
* one `homepage` field, so the site lives there and GitHub stays in `repository` (url + directory) and in
|
|
7
|
+
* `bugs.url`, and the package README carries both links. The rule therefore checks all of them: dropping the
|
|
8
|
+
* GitHub ones while "fixing" homepage is the exact regression the owner's clarification forbids.
|
|
9
|
+
*
|
|
10
|
+
* Every accepted value is an EXACT string from a closed list — no URL parsing, no normalisation (fix round 1,
|
|
11
|
+
* review r1 finding 1: a substring test accepted `https://evil.example/github.com/djd1m/dz-harness`). The lead
|
|
12
|
+
* measured all 57 manifests: each carries exactly the one value listed below.
|
|
13
|
+
*
|
|
14
|
+
* Two halves, so the evaluator is testable without a filesystem:
|
|
15
|
+
* - `npmHomepageFacts(records, discovery)` (here) is PURE: records in, per-package problem lists out;
|
|
16
|
+
* - `readNpmHomepageRecords(dir)` lives in harness-cli (`cli.ts`): it lists package directories (hidden ones
|
|
17
|
+
* included), parses `package.json`, reads `README.md`, and returns every entry it could NOT decide as a
|
|
18
|
+
* named discovery failure — a permission error is a failure, never "absent". It is NOT here because
|
|
19
|
+
* harness-core's IO ratchet (test/core-boundary.test.ts) forbids a new file importing node:fs —
|
|
20
|
+
* MEASURED 2026-09-28: 66 > pinned 65 when it lived here.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** The owner's site. Compared by strict equality: a trailing slash, a path or an anchor is a violation. */
|
|
24
|
+
export const NPM_HOMEPAGE_URL = 'https://aicoding.space';
|
|
25
|
+
/** Accepted `repository.url` values — a closed list, compared by strict equality. One entry today. */
|
|
26
|
+
export const NPM_REPOSITORY_URLS: readonly string[] = ['git+https://github.com/djd1m/dz-harness.git'];
|
|
27
|
+
/** The one accepted `bugs.url`. */
|
|
28
|
+
export const NPM_BUGS_URL = 'https://github.com/djd1m/dz-harness/issues';
|
|
29
|
+
const README_SITE = NPM_HOMEPAGE_URL;
|
|
30
|
+
const README_SOURCE = 'https://github.com/djd1m/dz-harness';
|
|
31
|
+
const PACKAGES_PREFIX = 'packages/@dzhechkov/';
|
|
32
|
+
|
|
33
|
+
/** One package directory as the reader found it. */
|
|
34
|
+
export interface NpmHomepageRecord {
|
|
35
|
+
/** The directory name under `packages/@dzhechkov/` — the expected tail of `repository.directory`. */
|
|
36
|
+
readonly dir: string;
|
|
37
|
+
readonly json?: unknown;
|
|
38
|
+
readonly parseError?: string;
|
|
39
|
+
/** README.md text; `null` = the file does not exist; absent = not read (treated as missing). */
|
|
40
|
+
readonly readme?: string | null;
|
|
41
|
+
/** README.md exists but could not be read. */
|
|
42
|
+
readonly readmeError?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** An entry the reader could not decide (stat/read/existence failed for a reason other than "absent"). */
|
|
46
|
+
export interface NpmHomepageDiscoveryFailure {
|
|
47
|
+
readonly path: string;
|
|
48
|
+
readonly reason: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What the reader saw besides the records. `unreadableRoot` set ⇒ the rule is NOT ESTABLISHED. */
|
|
52
|
+
export interface NpmHomepageDiscovery {
|
|
53
|
+
readonly failures?: readonly NpmHomepageDiscoveryFailure[];
|
|
54
|
+
readonly unreadableRoot?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The fact the guard rule reads: one entry per package; an empty `problems` list means compliant. */
|
|
58
|
+
export interface NpmHomepageFact {
|
|
59
|
+
readonly dir: string;
|
|
60
|
+
/** `name` from package.json when it is a string, else `packages/@dzhechkov/<dir>`. */
|
|
61
|
+
readonly name: string;
|
|
62
|
+
readonly problems: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The whole evidence set for the rule. */
|
|
66
|
+
export interface NpmHomepageFactSet {
|
|
67
|
+
readonly packages: readonly NpmHomepageFact[];
|
|
68
|
+
readonly discoveryFailures: readonly NpmHomepageDiscoveryFailure[];
|
|
69
|
+
readonly unreadableRoot?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
73
|
+
|
|
74
|
+
// Fix round 2 (review r2 finding 2): the two links must be WHOLE URLs outside code. A URL ends at end of line,
|
|
75
|
+
// whitespace, `)`, `]`, `>`, `,`, a quote, or a `.` followed by whitespace/end — so
|
|
76
|
+
// `https://aicoding.space.evil.example` and `https://aicoding.space/x` do not count. The source link may continue
|
|
77
|
+
// with `/` (a legitimate subpath such as `/tree/main/packages/…`), but not with `-imposter`.
|
|
78
|
+
const URL_END = String.raw`(?=$|[\s)\]>,"']|\.(?:\s|$))`;
|
|
79
|
+
const SITE_RE = new RegExp(`https://aicoding\\.space${URL_END}`);
|
|
80
|
+
const SOURCE_RE = new RegExp(`https://github\\.com/djd1m/dz-harness(?=/|${URL_END.slice(3)}`);
|
|
81
|
+
const FENCE_RE = /^\s{0,3}(```|~~~)/;
|
|
82
|
+
|
|
83
|
+
/** One README line, outside fenced code and outside inline-code spans, carrying BOTH whole links (any order). */
|
|
84
|
+
function readmeProblem(rec: NpmHomepageRecord): string | undefined {
|
|
85
|
+
if (rec.readmeError !== undefined) return `README.md could not be read (${rec.readmeError})`;
|
|
86
|
+
if (typeof rec.readme !== 'string') return 'README.md is missing — it must carry a line with both the site and the GitHub link';
|
|
87
|
+
let inFence = false;
|
|
88
|
+
let ok = false;
|
|
89
|
+
for (const line of rec.readme.split(/\r?\n/)) {
|
|
90
|
+
if (FENCE_RE.test(line)) { inFence = !inFence; continue; }
|
|
91
|
+
if (inFence) continue;
|
|
92
|
+
const prose = line.replace(/`[^`]*`/g, ' ');
|
|
93
|
+
if (SITE_RE.test(prose) && SOURCE_RE.test(prose)) { ok = true; break; }
|
|
94
|
+
}
|
|
95
|
+
return ok ? undefined : `README.md has no line (outside code) carrying both ${README_SITE} and ${README_SOURCE} as whole links`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Pure: evaluate each record against the field and README requirements. Private packages are NOT exempt. */
|
|
99
|
+
export function npmHomepageFacts(records: readonly NpmHomepageRecord[], discovery: NpmHomepageDiscovery = {}): NpmHomepageFactSet {
|
|
100
|
+
const out: NpmHomepageFact[] = [];
|
|
101
|
+
for (const rec of Array.isArray(records) ? records : []) {
|
|
102
|
+
const dir = typeof rec?.dir === 'string' ? rec.dir : '(unknown dir)';
|
|
103
|
+
const fallbackName = `${PACKAGES_PREFIX}${dir}`;
|
|
104
|
+
const readme = rec && typeof rec === 'object' ? readmeProblem(rec) : 'record is not an object';
|
|
105
|
+
if (rec?.parseError !== undefined) {
|
|
106
|
+
out.push({ dir, name: fallbackName, problems: [`package.json does not parse (${rec.parseError})`, ...(readme ? [readme] : [])] });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const j = rec?.json;
|
|
110
|
+
if (!isObject(j)) {
|
|
111
|
+
out.push({ dir, name: fallbackName, problems: ['package.json is not a JSON object', ...(readme ? [readme] : [])] });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const name = typeof j['name'] === 'string' && j['name'] !== '' ? j['name'] : fallbackName;
|
|
115
|
+
const problems: string[] = [];
|
|
116
|
+
if (j['homepage'] !== NPM_HOMEPAGE_URL) {
|
|
117
|
+
problems.push(`homepage = ${JSON.stringify(j['homepage'])}, must be exactly "${NPM_HOMEPAGE_URL}"`);
|
|
118
|
+
}
|
|
119
|
+
const repo = j['repository'];
|
|
120
|
+
const repoUrl = isObject(repo) ? repo['url'] : undefined;
|
|
121
|
+
if (typeof repoUrl !== 'string' || !NPM_REPOSITORY_URLS.includes(repoUrl)) {
|
|
122
|
+
problems.push(`repository.url = ${JSON.stringify(repoUrl)}, must be exactly one of ${JSON.stringify(NPM_REPOSITORY_URLS)}`);
|
|
123
|
+
}
|
|
124
|
+
const repoDir = isObject(repo) ? repo['directory'] : undefined;
|
|
125
|
+
if (repoDir !== `${PACKAGES_PREFIX}${dir}`) {
|
|
126
|
+
problems.push(`repository.directory = ${JSON.stringify(repoDir)}, must be "${PACKAGES_PREFIX}${dir}"`);
|
|
127
|
+
}
|
|
128
|
+
const bugs = j['bugs'];
|
|
129
|
+
const bugsUrl = isObject(bugs) ? bugs['url'] : undefined;
|
|
130
|
+
if (bugsUrl !== NPM_BUGS_URL) {
|
|
131
|
+
problems.push(`bugs.url = ${JSON.stringify(bugsUrl)}, must be "${NPM_BUGS_URL}"`);
|
|
132
|
+
}
|
|
133
|
+
if (readme !== undefined) problems.push(readme);
|
|
134
|
+
out.push({ dir, name, problems });
|
|
135
|
+
}
|
|
136
|
+
const failures = Array.isArray(discovery?.failures) ? [...discovery.failures] : [];
|
|
137
|
+
return {
|
|
138
|
+
packages: out,
|
|
139
|
+
discoveryFailures: failures,
|
|
140
|
+
...(typeof discovery?.unreadableRoot === 'string' ? { unreadableRoot: discovery.unreadableRoot } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|