@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -12
- package/data/yunnan-pack.json +30 -27
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/channel-registry.js +4 -4
- package/dist/capabilities/doctor.js +14 -7
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/extension-bridge.js +3 -3
- package/dist/capabilities/session-search.js +1 -1
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +77 -7
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +39 -3
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +621 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-dida-live-e2e.js +1 -1
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +79 -2
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/backend/modules/session-search.js +1 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +166 -7
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/README.md +1 -1
- package/extension/README.zh-CN.md +1 -1
- package/extension/background.js +1 -1
- package/extension/content-bridge.js +1 -1
- package/extension/content-main.js +1 -1
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/capabilities/session/extension-bridge.ts +3 -3
- package/ts/capabilities/session-search.ts +1 -1
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +181 -8
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider fault-detail sanitizer (shared, pure, zero imports).
|
|
3
|
+
*
|
|
4
|
+
* A provider's failure text is a LABEL for operators, never a channel for a
|
|
5
|
+
* provider response body. Any string that may reach the evidence/verdict surface
|
|
6
|
+
* passes through `sanitizeFaultDetail` first: markup, credential assignments and
|
|
7
|
+
* URLs are redacted, whitespace is collapsed, and the result is length-bounded.
|
|
8
|
+
* `faultDetailLeak` is the mechanical check a gate (or a test) can run over a
|
|
9
|
+
* string that is about to be exposed.
|
|
10
|
+
*
|
|
11
|
+
* Extracted so that exactly one implementation serves both
|
|
12
|
+
* `ts/capabilities/route-provider-conformance.ts` (the #429 conformance gate,
|
|
13
|
+
* which re-exports it) and `ts/capabilities/ground-transfer.ts` (the live
|
|
14
|
+
* product path, issue #429 GAP-429-3 fix). Keeping it in its own file means the
|
|
15
|
+
* conformance gate itself stays free of product callers.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately pure: zero imports, zero IO, zero network, zero clock.
|
|
18
|
+
*
|
|
19
|
+
* @module capabilities/provider-detail-sanitize
|
|
20
|
+
*/
|
|
21
|
+
/** Default bound for a provider fragment embedded in a gate refusal. */
|
|
22
|
+
export const FAULT_DETAIL_MAX_CHARS = 120;
|
|
23
|
+
const DETAIL_SCRUB_PATTERNS = [
|
|
24
|
+
{ re: /<[^>]*>/g, label: 'markup' },
|
|
25
|
+
{ re: /\b(?:cookie|set-cookie|authorization|api[_-]?key|token|secret|password|passwd|sid)\s*[:=]\s*\S+/gi, label: 'credential' },
|
|
26
|
+
{ re: /\bbearer\s+[A-Za-z0-9._-]{8,}/gi, label: 'credential' },
|
|
27
|
+
{ re: /https?:\/\/\S+/gi, label: 'url' },
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Sanitize a provider fault detail.
|
|
31
|
+
*
|
|
32
|
+
* `maxChars` lets a caller keep its own historical bound (ground-transfer keeps
|
|
33
|
+
* 400 so no existing `fallbackReason` text changes); `emptyPlaceholder` lets a
|
|
34
|
+
* caller keep its own historical empty-case wording.
|
|
35
|
+
*/
|
|
36
|
+
export function sanitizeFaultDetail(detail, options) {
|
|
37
|
+
const maxChars = options?.maxChars ?? FAULT_DETAIL_MAX_CHARS;
|
|
38
|
+
const placeholder = options?.emptyPlaceholder ?? '(no detail)';
|
|
39
|
+
let text = typeof detail === 'string' ? detail : '';
|
|
40
|
+
for (const { re, label } of DETAIL_SCRUB_PATTERNS)
|
|
41
|
+
text = text.replace(re, `[${label}-redacted]`);
|
|
42
|
+
text = text.replace(/\s+/g, ' ').trim();
|
|
43
|
+
if (text.length > maxChars)
|
|
44
|
+
text = `${text.slice(0, maxChars - 1)}…`;
|
|
45
|
+
return text === '' ? placeholder : text;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Mechanical leak check for any string about to reach the evidence surface.
|
|
49
|
+
*
|
|
50
|
+
* Shape-only by design: it looks for provider-response-body tells (markup,
|
|
51
|
+
* credential assignments, URLs) and deliberately does NOT bound length — the
|
|
52
|
+
* length bound belongs to `sanitizeFaultDetail`, which caps the PROVIDER
|
|
53
|
+
* fragment, whereas a gate's own refusal explanation is host-authored prose and
|
|
54
|
+
* may legitimately be longer.
|
|
55
|
+
*/
|
|
56
|
+
export function faultDetailLeak(detail) {
|
|
57
|
+
if (/<[^>]*>/.test(detail))
|
|
58
|
+
return 'fault detail carries markup (a provider response body must never reach the evidence surface)';
|
|
59
|
+
if (/\b(?:cookie|authorization|api[_-]?key|token|secret|password|passwd|sid)\s*[:=]\s*\S+/i.test(detail))
|
|
60
|
+
return 'fault detail carries a credential assignment';
|
|
61
|
+
if (/https?:\/\//i.test(detail))
|
|
62
|
+
return 'fault detail carries a URL';
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D-39 live-route provider CONFORMANCE contract v1 (issue #429 contract slice,
|
|
3
|
+
* SIMULATED-TRIGGER DRILL — default OFF, zero callers, trigger flag frozen false,
|
|
4
|
+
* provider registry frozen EMPTY).
|
|
5
|
+
*
|
|
6
|
+
* ⚠ Evidence boundary: this is the conformance GATE any future live-route adapter
|
|
7
|
+
* must pass, exercised here against a MOCK adapter only. #429's trigger is, per
|
|
8
|
+
* path, "a named product use case + the actual data provider and access boundary +
|
|
9
|
+
* reviewable authority/freshness/coverage evidence"; it has NOT fired for any path.
|
|
10
|
+
* No real routing provider is contacted (`consultRouteProvider` refuses before
|
|
11
|
+
* touching an adapter while the trigger is false), no dependency is added, and the
|
|
12
|
+
* accepted narrow path of #341 (explicit-coordinate destination driving estimate
|
|
13
|
+
* with static price evidence) is untouched. Nothing here claims provider
|
|
14
|
+
* availability or an M4/M5/M6 Exit.
|
|
15
|
+
*
|
|
16
|
+
* What it owns: the single admission gate `admitRouteFact` — a provider outcome
|
|
17
|
+
* becomes a route fact only if the provider's OWN claims (mode, evidence class,
|
|
18
|
+
* echoed origin/destination, freshness, fare authority) match the declared use
|
|
19
|
+
* case. Every mismatch is a typed refusal whose detail is sanitized, and a refusal
|
|
20
|
+
* always leaves the static fallback and its original price label intact.
|
|
21
|
+
*
|
|
22
|
+
* Frozen upstream constraints (READ-ONLY reuse):
|
|
23
|
+
* - docs/architecture.md D-39 + §10: each wider path needs its own named use case,
|
|
24
|
+
* source, freshness contract, access boundary and real-data proof; read-only
|
|
25
|
+
* routing authorizes no write path;
|
|
26
|
+
* - ts/capabilities/ground-transfer.ts (#341/#364/#382): the accepted narrow shape
|
|
27
|
+
* — per-direction isolation, host observation time (never a provider publication
|
|
28
|
+
* timestamp), `trafficStatus: 'not-live-route-estimate'`, and the static
|
|
29
|
+
* `priceCny` + `[静态包:估算]` label that a route fact never replaces;
|
|
30
|
+
* - ts/capabilities/fx-contract.ts (#344): the trigger-gated, frozen-empty
|
|
31
|
+
* registry seam and the "stale/miss/error never masquerades as current" rule.
|
|
32
|
+
*
|
|
33
|
+
* Layering: this module performs zero network, cache, timer, clock or filesystem
|
|
34
|
+
* work, and its ONLY import is the pure sibling sanitizer
|
|
35
|
+
* `provider-detail-sanitize.ts` (shared with the live ground-transfer path so one
|
|
36
|
+
* implementation serves both). It imports nothing from the kernel evaluate/solve
|
|
37
|
+
* layer (`src/model.ts`, `src/unified.ts`), and no product file imports it. Any
|
|
38
|
+
* future adapter's network/cache work stays on the capability side of that line;
|
|
39
|
+
* the focused suite asserts the import surface mechanically.
|
|
40
|
+
*
|
|
41
|
+
* Only the real trigger can supply: the provider identity and licence, actual
|
|
42
|
+
* coverage/freshness measurements, a separately authoritative fare source, and an
|
|
43
|
+
* authorized minimal real path recorded at an exact source SHA.
|
|
44
|
+
*
|
|
45
|
+
* @module capabilities/route-provider-conformance
|
|
46
|
+
*/
|
|
47
|
+
import { FAULT_DETAIL_MAX_CHARS, faultDetailLeak, sanitizeFaultDetail } from './provider-detail-sanitize.js';
|
|
48
|
+
/** Re-exported so the conformance gate stays the single API surface for callers. */
|
|
49
|
+
export { FAULT_DETAIL_MAX_CHARS, faultDetailLeak, sanitizeFaultDetail };
|
|
50
|
+
export const ROUTE_PROVIDER_CONFORMANCE_SCHEMA = 'gotry_route_provider_conformance.v1';
|
|
51
|
+
/**
|
|
52
|
+
* D-39 wider-path trigger gate, frozen false. Each path (live traffic, transit,
|
|
53
|
+
* rail, fare, address resolution) is admitted independently; flipping this is a
|
|
54
|
+
* per-path founder decision with a named use case and a real provider.
|
|
55
|
+
*/
|
|
56
|
+
export const D39_LIVE_ROUTE_TRIGGER_FIRED = false;
|
|
57
|
+
/** Transport modes. A provider may never relabel one as another, in either direction. */
|
|
58
|
+
export const ROUTE_MODES = ['driving', 'transit', 'rail', 'walking', 'cycling'];
|
|
59
|
+
/** Evidence classes, weakest first. A declared class must match EXACTLY. */
|
|
60
|
+
export const ROUTE_EVIDENCE_CLASSES = ['static_estimate', 'route_estimate', 'live_traffic'];
|
|
61
|
+
/** Access boundaries. A read-only routing admission never implies a write path. */
|
|
62
|
+
export const ROUTE_ACCESS_BOUNDARIES = ['read_only_public', 'licensed_read_only', 'supplier_transaction'];
|
|
63
|
+
/** Fault modes every candidate provider must be probed against before admission. */
|
|
64
|
+
export const ROUTE_PROVIDER_FAULT_MODES = [
|
|
65
|
+
'unavailable',
|
|
66
|
+
'stale',
|
|
67
|
+
'mismatched_direction',
|
|
68
|
+
'mode_relabel',
|
|
69
|
+
'estimate_as_live_traffic',
|
|
70
|
+
'challenge',
|
|
71
|
+
'rate_limited',
|
|
72
|
+
'partial_result',
|
|
73
|
+
];
|
|
74
|
+
/** Conformance clauses. A future adapter is admissible only if ALL of them pass. */
|
|
75
|
+
export const ROUTE_CONFORMANCE_CLAUSES = [
|
|
76
|
+
/** the response must echo the requested origin/destination; the swapped pair is a different fact */
|
|
77
|
+
'direction_binding',
|
|
78
|
+
/** the provider's own mode claim must equal the use case's mode */
|
|
79
|
+
'mode_isolation',
|
|
80
|
+
/** the provider's own evidence class must equal the use case's (no escalation, no shortfall) */
|
|
81
|
+
'evidence_class_isolation',
|
|
82
|
+
/** a provider outside the freshness contract is stale, never current */
|
|
83
|
+
'freshness_contract',
|
|
84
|
+
/** every admitted fact names its source identity and access boundary */
|
|
85
|
+
'source_identity',
|
|
86
|
+
/** every fault mode fails closed with a typed code — never a silent degrade */
|
|
87
|
+
'fault_fail_closed',
|
|
88
|
+
/** a refusal leaves the static fallback minutes, price and original label untouched */
|
|
89
|
+
'static_fallback_preserved',
|
|
90
|
+
/** a route provider is never a fare authority unless the use case declares a separate one */
|
|
91
|
+
'fare_authority_separate',
|
|
92
|
+
/** fault details are sanitized: no provider response body, markup, cookie or token */
|
|
93
|
+
'fault_detail_sanitized',
|
|
94
|
+
];
|
|
95
|
+
function fail(code, detail) {
|
|
96
|
+
return { ok: false, code, detail };
|
|
97
|
+
}
|
|
98
|
+
function pass(value) {
|
|
99
|
+
return { ok: true, value };
|
|
100
|
+
}
|
|
101
|
+
/** Runtime provider registry, frozen EMPTY. A non-empty value is a founder decision. */
|
|
102
|
+
export const ADMITTED_LIVE_ROUTE_PROVIDERS = [];
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Detail sanitization: see ./provider-detail-sanitize.ts (shared with the live
|
|
105
|
+
// ground-transfer path; re-exported above so this gate stays the single API).
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// The admission gate
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
function parseInstant(value, label) {
|
|
111
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
112
|
+
return fail('bad_ts', `${label} must be a non-empty ISO-8601 instant, got ${JSON.stringify(value)}`);
|
|
113
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value.trim()))
|
|
114
|
+
return fail('bad_ts', `${label} must carry a full time-of-day (date-only '${value}' cannot be aged)`);
|
|
115
|
+
const ms = Date.parse(value);
|
|
116
|
+
if (!Number.isFinite(ms))
|
|
117
|
+
return fail('bad_ts', `${label} is not a parseable ISO-8601 instant: ${JSON.stringify(value)}`);
|
|
118
|
+
return pass(new Date(ms).toISOString());
|
|
119
|
+
}
|
|
120
|
+
/** Map a provider fault to its typed refusal code (closed, total mapping). */
|
|
121
|
+
const FAULT_CODE = {
|
|
122
|
+
unavailable: 'unavailable',
|
|
123
|
+
stale: 'stale',
|
|
124
|
+
mismatched_direction: 'direction_mismatch',
|
|
125
|
+
mode_relabel: 'mode_mismatch',
|
|
126
|
+
estimate_as_live_traffic: 'evidence_class_escalation',
|
|
127
|
+
challenge: 'challenge',
|
|
128
|
+
rate_limited: 'rate_limited',
|
|
129
|
+
partial_result: 'partial_result',
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* The single admission gate: a provider outcome becomes a route fact only if every
|
|
133
|
+
* declared claim matches the named use case. Each refusal is typed and sanitized;
|
|
134
|
+
* no refusal ever degrades into a weaker-but-accepted fact.
|
|
135
|
+
*/
|
|
136
|
+
export function admitRouteFact(useCase, descriptor, request, outcome, now) {
|
|
137
|
+
if (typeof useCase?.id !== 'string' || useCase.id.trim() === '')
|
|
138
|
+
return fail('binding_incomplete', 'use case id is mandatory: a path is admitted per NAMED product use case, never globally');
|
|
139
|
+
if (!ROUTE_MODES.includes(useCase.mode))
|
|
140
|
+
return fail('closed_set', `use case mode must be one of [${ROUTE_MODES.join('/')}], got ${JSON.stringify(useCase.mode)}`);
|
|
141
|
+
if (!ROUTE_EVIDENCE_CLASSES.includes(useCase.evidence_class))
|
|
142
|
+
return fail('closed_set', `use case evidence_class must be one of [${ROUTE_EVIDENCE_CLASSES.join('/')}], got ${JSON.stringify(useCase.evidence_class)}`);
|
|
143
|
+
if (!ROUTE_ACCESS_BOUNDARIES.includes(useCase.access_boundary))
|
|
144
|
+
return fail('closed_set', `use case access_boundary must be one of [${ROUTE_ACCESS_BOUNDARIES.join('/')}]`);
|
|
145
|
+
if (!Number.isFinite(useCase.freshness_max_age_s) || useCase.freshness_max_age_s <= 0)
|
|
146
|
+
return fail('binding_incomplete', 'use case freshness_max_age_s must be a positive number of seconds (a path without a freshness contract is not admitted)');
|
|
147
|
+
if (typeof descriptor?.id !== 'string' || descriptor.id.trim() === '')
|
|
148
|
+
return fail('binding_incomplete', 'provider descriptor id is mandatory (source identity)');
|
|
149
|
+
if (typeof descriptor.legal_basis !== 'string' || descriptor.legal_basis.trim() === '')
|
|
150
|
+
return fail('binding_incomplete', `provider ${descriptor.id} carries no legal_basis — admission requires a stated licence / access boundary`);
|
|
151
|
+
if (typeof descriptor.source_sha !== 'string' || descriptor.source_sha.trim() === '')
|
|
152
|
+
return fail('binding_incomplete', `provider ${descriptor.id} carries no source_sha — D-39 requires the exact reviewed source revision`);
|
|
153
|
+
if (descriptor.access_boundary !== useCase.access_boundary) {
|
|
154
|
+
return fail('closed_set', `provider ${descriptor.id} access boundary '${descriptor.access_boundary}' does not match use case '${useCase.id}' boundary '${useCase.access_boundary}' — a read-only routing admission never widens into a transaction boundary`);
|
|
155
|
+
}
|
|
156
|
+
if (typeof request?.origin !== 'string' || request.origin.trim() === '' || typeof request?.destination !== 'string' || request.destination.trim() === '') {
|
|
157
|
+
return fail('binding_incomplete', 'request origin and destination are mandatory (direction binding has nothing to bind otherwise)');
|
|
158
|
+
}
|
|
159
|
+
if (request.mode !== useCase.mode) {
|
|
160
|
+
return fail('mode_mismatch', `request mode '${request.mode}' contradicts use case '${useCase.id}' mode '${useCase.mode}'`);
|
|
161
|
+
}
|
|
162
|
+
if (!outcome?.ok) {
|
|
163
|
+
if (!ROUTE_PROVIDER_FAULT_MODES.includes(outcome?.fault)) {
|
|
164
|
+
return fail('closed_set', `provider fault must be one of [${ROUTE_PROVIDER_FAULT_MODES.join('/')}], got ${JSON.stringify(outcome?.fault)} — an unclassified failure is not a degrade licence`);
|
|
165
|
+
}
|
|
166
|
+
return fail(FAULT_CODE[outcome.fault], `provider ${descriptor.id} fault '${outcome.fault}': ${sanitizeFaultDetail(outcome.detail)}`);
|
|
167
|
+
}
|
|
168
|
+
const response = outcome.response;
|
|
169
|
+
if (typeof response?.provider_id !== 'string' || response.provider_id.trim() === '')
|
|
170
|
+
return fail('binding_incomplete', 'response provider_id is mandatory (source identity per fact)');
|
|
171
|
+
if (response.provider_id !== descriptor.id) {
|
|
172
|
+
return fail('binding_incomplete', `response provider_id '${response.provider_id}' does not match the admitted descriptor '${descriptor.id}' — a fact may not be attributed to a provider that did not produce it`);
|
|
173
|
+
}
|
|
174
|
+
if (!ROUTE_MODES.includes(response.mode)) {
|
|
175
|
+
return fail('closed_set', `response mode must be one of [${ROUTE_MODES.join('/')}], got ${JSON.stringify(response.mode)} — a response without an explicit mode claim may not be labelled by the request`);
|
|
176
|
+
}
|
|
177
|
+
if (response.mode !== useCase.mode) {
|
|
178
|
+
return fail('mode_mismatch', `provider ${descriptor.id} returned mode '${response.mode}' for a '${useCase.mode}' use case — ${response.mode} must never be relabelled as ${useCase.mode} (nor the reverse)`);
|
|
179
|
+
}
|
|
180
|
+
if (!ROUTE_EVIDENCE_CLASSES.includes(response.evidence_class)) {
|
|
181
|
+
return fail('closed_set', `response evidence_class must be one of [${ROUTE_EVIDENCE_CLASSES.join('/')}], got ${JSON.stringify(response.evidence_class)}`);
|
|
182
|
+
}
|
|
183
|
+
if (response.evidence_class !== useCase.evidence_class) {
|
|
184
|
+
const providerRank = ROUTE_EVIDENCE_CLASSES.indexOf(response.evidence_class);
|
|
185
|
+
const useCaseRank = ROUTE_EVIDENCE_CLASSES.indexOf(useCase.evidence_class);
|
|
186
|
+
return providerRank > useCaseRank
|
|
187
|
+
? fail('evidence_class_escalation', `provider ${descriptor.id} claims '${response.evidence_class}' for a '${useCase.evidence_class}' use case — an estimate must never be presented as ${response.evidence_class}`)
|
|
188
|
+
: fail('evidence_class_shortfall', `provider ${descriptor.id} delivered '${response.evidence_class}' for a '${useCase.evidence_class}' use case — a weaker class may not fill a stronger promise`);
|
|
189
|
+
}
|
|
190
|
+
if (response.echoed_origin !== request.origin || response.echoed_destination !== request.destination) {
|
|
191
|
+
return fail('direction_mismatch', `provider ${descriptor.id} routed ${response.echoed_origin}→${response.echoed_destination} but the ${request.direction} request was ${request.origin}→${request.destination} — direction binding is verified against the RESPONSE, not assumed from the request`);
|
|
192
|
+
}
|
|
193
|
+
const distance = response.distance_m;
|
|
194
|
+
const duration = response.duration_s;
|
|
195
|
+
if (typeof distance !== 'number' || !Number.isFinite(distance) || distance < 0
|
|
196
|
+
|| typeof duration !== 'number' || !Number.isFinite(duration) || duration < 0
|
|
197
|
+
|| (distance > 0 && duration === 0)) {
|
|
198
|
+
return fail('partial_result', `provider ${descriptor.id} returned an incomplete or self-contradictory route (distance_m=${String(distance)}, duration_s=${String(duration)}); a positive distance cannot be covered in zero time`);
|
|
199
|
+
}
|
|
200
|
+
const nowParsed = parseInstant(now, 'gate clock now');
|
|
201
|
+
if (!nowParsed.ok)
|
|
202
|
+
return nowParsed;
|
|
203
|
+
const observedAt = parseInstant(response.observed_at, 'response observed_at (host observation instant)');
|
|
204
|
+
if (!observedAt.ok)
|
|
205
|
+
return observedAt;
|
|
206
|
+
if (observedAt.value > nowParsed.value) {
|
|
207
|
+
return fail('bad_ts', `observed_at ${observedAt.value} postdates the gate clock ${nowParsed.value} — a provider publication timestamp is not a host observation instant`);
|
|
208
|
+
}
|
|
209
|
+
const ageS = (Date.parse(nowParsed.value) - Date.parse(observedAt.value)) / 1000;
|
|
210
|
+
if (ageS > useCase.freshness_max_age_s) {
|
|
211
|
+
return fail('stale', `observation is ${ageS}s old, beyond the '${useCase.id}' freshness contract of ${useCase.freshness_max_age_s}s — stale never masquerades as current`);
|
|
212
|
+
}
|
|
213
|
+
let fare = null;
|
|
214
|
+
if (response.fare !== undefined) {
|
|
215
|
+
if (useCase.fare_authority === 'none') {
|
|
216
|
+
return fail('fare_not_authoritative', `provider ${descriptor.id} offered a fare for use case '${useCase.id}' which declares no fare authority — the static price and its original label stand until a separately authoritative fare exists`);
|
|
217
|
+
}
|
|
218
|
+
const f = response.fare;
|
|
219
|
+
if (typeof f.amount !== 'string' || f.amount.trim() === '' || typeof f.currency !== 'string' || f.currency.trim() === '' || typeof f.authority !== 'string' || f.authority.trim() === '') {
|
|
220
|
+
return fail('binding_incomplete', 'an admitted fare needs amount, currency and a named authority');
|
|
221
|
+
}
|
|
222
|
+
if (f.authority === descriptor.id || f.authority === response.provider_id) {
|
|
223
|
+
return fail('fare_not_authoritative', `a route provider ('${descriptor.id}') is never its own fare authority — '${useCase.id}' requires a separately authoritative fare source`);
|
|
224
|
+
}
|
|
225
|
+
fare = { amount: f.amount, currency: f.currency, authority: f.authority };
|
|
226
|
+
}
|
|
227
|
+
return pass(Object.freeze({
|
|
228
|
+
schema: ROUTE_PROVIDER_CONFORMANCE_SCHEMA,
|
|
229
|
+
use_case_id: useCase.id,
|
|
230
|
+
direction: request.direction,
|
|
231
|
+
origin: request.origin,
|
|
232
|
+
destination: request.destination,
|
|
233
|
+
mode: useCase.mode,
|
|
234
|
+
evidence_class: useCase.evidence_class,
|
|
235
|
+
provider_id: descriptor.id,
|
|
236
|
+
access_boundary: descriptor.access_boundary,
|
|
237
|
+
source_sha: descriptor.source_sha,
|
|
238
|
+
distance_m: distance,
|
|
239
|
+
duration_s: duration,
|
|
240
|
+
duration_min: Math.round(duration / 60),
|
|
241
|
+
observed_at: observedAt.value,
|
|
242
|
+
age_s: ageS,
|
|
243
|
+
fare,
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The only entry point to a live route adapter. While `D39_LIVE_ROUTE_TRIGGER_FIRED`
|
|
248
|
+
* is false this refuses BEFORE touching the adapter, so zero routing providers are
|
|
249
|
+
* contacted (the fetch-spy zero-call proof in run-all §87).
|
|
250
|
+
*/
|
|
251
|
+
export async function consultRouteProvider(adapter, useCase, request,
|
|
252
|
+
/** `now` is MANDATORY: this module reads no clock, so the caller injects one. */
|
|
253
|
+
options) {
|
|
254
|
+
const triggerFired = options?.triggerFired ?? D39_LIVE_ROUTE_TRIGGER_FIRED;
|
|
255
|
+
if (!triggerFired) {
|
|
256
|
+
return fail('trigger_deferred', `D-39 path trigger has not fired for use case '${String(useCase?.id)}' (#429: a named product use case + the actual provider and access boundary + reviewable authority/freshness/coverage evidence); zero routing providers contacted, static fallback stands`);
|
|
257
|
+
}
|
|
258
|
+
const registry = options?.registry ?? ADMITTED_LIVE_ROUTE_PROVIDERS;
|
|
259
|
+
if (!registry.some(d => d.id === adapter?.descriptor?.id)) {
|
|
260
|
+
return fail('provider_not_admitted', `provider '${String(adapter?.descriptor?.id)}' is not in the admitted registry (admitted: [${registry.map(d => d.id).join('/') || 'none'}]); admitting one is a per-path founder decision with a legal_basis and source_sha`);
|
|
261
|
+
}
|
|
262
|
+
const outcome = await adapter.fetchRoute(request, { signal: options?.signal ?? new AbortController().signal });
|
|
263
|
+
return admitRouteFact(useCase, adapter.descriptor, request, outcome, options.now);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Mechanical check: after a refusal, the static fallback must be unchanged —
|
|
267
|
+
* same mode, same minutes, same price and the SAME original price label. Returns
|
|
268
|
+
* a violation string, or null.
|
|
269
|
+
*/
|
|
270
|
+
export function staticFallbackViolation(before, after) {
|
|
271
|
+
if (before.mode !== after.mode)
|
|
272
|
+
return `static fallback mode changed '${before.mode}' → '${after.mode}'`;
|
|
273
|
+
if (before.minutes !== after.minutes)
|
|
274
|
+
return `static fallback minutes changed ${before.minutes} → ${after.minutes}: a refused route fact must not touch the static estimate`;
|
|
275
|
+
if (before.priceCny !== after.priceCny)
|
|
276
|
+
return `static fallback priceCny changed ${before.priceCny} → ${after.priceCny}: a route fact never supplies a price`;
|
|
277
|
+
if (before.priceEvidence !== after.priceEvidence)
|
|
278
|
+
return `static price label changed '${before.priceEvidence}' → '${after.priceEvidence}': the original label stands until a separately authoritative fare exists`;
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Run the conformance probes and report per clause.
|
|
283
|
+
*
|
|
284
|
+
* `admissible` is true only when every clause in `ROUTE_CONFORMANCE_CLAUSES` was
|
|
285
|
+
* probed AND passed: an unprobed clause is reported as unprobed and blocks
|
|
286
|
+
* admission, because "we never tested that" is not evidence of conformance.
|
|
287
|
+
*/
|
|
288
|
+
export function runRouteProviderConformance(useCase, descriptor, probes, now) {
|
|
289
|
+
const byClause = new Map();
|
|
290
|
+
for (const probe of probes) {
|
|
291
|
+
const slot = byClause.get(probe.clause) ?? { pass: true, count: 0, evidence: [] };
|
|
292
|
+
slot.count++;
|
|
293
|
+
const result = admitRouteFact(useCase, descriptor, probe.request, probe.outcome, now);
|
|
294
|
+
if (probe.expect === null) {
|
|
295
|
+
if (result.ok)
|
|
296
|
+
slot.evidence.push(`${probe.label}: admitted as expected`);
|
|
297
|
+
else {
|
|
298
|
+
slot.pass = false;
|
|
299
|
+
slot.evidence.push(`${probe.label}: expected admission, got refusal ${result.code} — ${result.detail}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (result.ok) {
|
|
303
|
+
slot.pass = false;
|
|
304
|
+
slot.evidence.push(`${probe.label}: expected refusal ${probe.expect}, got ADMISSION (a fault was silently accepted)`);
|
|
305
|
+
}
|
|
306
|
+
else if (result.code !== probe.expect) {
|
|
307
|
+
slot.pass = false;
|
|
308
|
+
slot.evidence.push(`${probe.label}: expected refusal ${probe.expect}, got ${result.code} — ${result.detail}`);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
const leak = faultDetailLeak(result.detail);
|
|
312
|
+
if (leak !== null) {
|
|
313
|
+
slot.pass = false;
|
|
314
|
+
slot.evidence.push(`${probe.label}: refused ${result.code} but the detail leaks — ${leak}`);
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
slot.evidence.push(`${probe.label}: refused ${result.code} with a sanitized detail`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
byClause.set(probe.clause, slot);
|
|
321
|
+
}
|
|
322
|
+
const clauses = [];
|
|
323
|
+
const unprobed = [];
|
|
324
|
+
for (const clause of ROUTE_CONFORMANCE_CLAUSES) {
|
|
325
|
+
const slot = byClause.get(clause);
|
|
326
|
+
if (slot === undefined) {
|
|
327
|
+
unprobed.push(clause);
|
|
328
|
+
clauses.push({ clause, verdict: 'fail', probes: 0, evidence: ['clause was never probed — "untested" is not conformance'] });
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
clauses.push({ clause, verdict: slot.pass ? 'pass' : 'fail', probes: slot.count, evidence: Object.freeze([...slot.evidence]) });
|
|
332
|
+
}
|
|
333
|
+
return Object.freeze({
|
|
334
|
+
schema: ROUTE_PROVIDER_CONFORMANCE_SCHEMA,
|
|
335
|
+
use_case_id: useCase.id,
|
|
336
|
+
provider_id: descriptor.id,
|
|
337
|
+
admissible: unprobed.length === 0 && clauses.every(c => c.verdict === 'pass'),
|
|
338
|
+
clauses: Object.freeze([...clauses]),
|
|
339
|
+
unprobed: Object.freeze([...unprobed]),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
@@ -58,17 +58,17 @@ export function localExtensionInstalled(home = homedir()) {
|
|
|
58
58
|
* 影响面统一行:账号会话通道断 → 列表底部工具不提供;其余工具不受影响。
|
|
59
59
|
* 纯函数(默认 HOME 可注入)。
|
|
60
60
|
*
|
|
61
|
-
* 双语对照:CN=本函数返回值;EN 权威见 docs/capability-onboarding.md「
|
|
61
|
+
* 双语对照:CN=本函数返回值;EN 权威见 docs/capability-onboarding.md「Stai Travel Bridge 安装」段。
|
|
62
62
|
*/
|
|
63
63
|
const EXTENSION_IMPACT_LINE = '影响面:gotry_session_search / gotry_session_login(账号会话通道)不可用;携程机票/酒店实时检索、12306 余票、Dida 实时报价都依赖此通道。其它工具(机票 FlyAI、酒店 hbcli、地图、天气等)不受影响。';
|
|
64
64
|
export function needsExtensionSummary(opts = {}) {
|
|
65
65
|
const local = opts.localInstalled ?? localExtensionInstalled(opts.home);
|
|
66
66
|
if (local) {
|
|
67
|
-
return '
|
|
67
|
+
return 'Stai Travel Bridge 扩展未连接(未安装或已停用)。推荐 Chrome 应用商店一键装(自动更新) '
|
|
68
68
|
+ `${EXTENSION_STORE_URL} ;或 npx @danceiny/gotry setup 落位后 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension。`
|
|
69
69
|
+ EXTENSION_IMPACT_LINE;
|
|
70
70
|
}
|
|
71
|
-
return '
|
|
71
|
+
return 'Stai Travel Bridge 扩展未连接。若你已从 Chrome 商店安装(自动更新):打开 Chrome 并确认扩展已启用即可自动连接,无需再装。'
|
|
72
72
|
+ `若尚未安装:应用商店一键装 ${EXTENSION_STORE_URL} (装完零弹窗,浏览器自己当安装器)。`
|
|
73
73
|
+ EXTENSION_IMPACT_LINE;
|
|
74
74
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 会话检索编排层(RFC §3.2):transport → adapter entry → networkHint 嗅探 → 解析 → ReadGuard → 证据链。
|
|
3
3
|
*
|
|
4
4
|
* 传输车道(2026-08-29 定案,RFC §2.2):**扩展桥为默认**——一次性安装的 MV3 扩展
|
|
5
|
-
* (
|
|
5
|
+
* (Stai Travel Bridge)在自己标签页被动嗅探 batchSearch,零 Chrome 系统弹窗;
|
|
6
6
|
* cdp(attach 日常 Chrome,Chrome 144+ 每连接弹权限框)降为显式后备
|
|
7
7
|
* (`GOTRY_SESSION_TRANSPORT=cdp` opt-in,诊断/测试用);persistent 仅测试。
|
|
8
8
|
*
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline tests for the Anything-path measurement probe (issue #276 / #345 evidence probe).
|
|
3
|
+
*
|
|
4
|
+
* Run: cd ts && npx tsx scripts/anything-path-measure-tests.ts
|
|
5
|
+
*
|
|
6
|
+
* Fully offline: the real hbcli runner is never used; a scripted fake runner stands in. The opt-in
|
|
7
|
+
* claim is executed, not asserted: with the environment flag missing, the fake runner must be
|
|
8
|
+
* called exactly zero times.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { CONCURRENCY, OPT_IN_ENV, fieldPresence, latencyStats, measure, measureIfOptedIn, percentile, scrub, } from './anything-path-measure.js';
|
|
12
|
+
const tests = [];
|
|
13
|
+
const test = (name, run) => tests.push({ name, run });
|
|
14
|
+
const hotel = (extra = {}) => ({ type: 'hotel', hotel: { name: 'H', star: 4, address: 'a', ...extra } });
|
|
15
|
+
const city = () => ({ type: 'city', region: { name: 'C', countryCode: 'CN', extra: '' } });
|
|
16
|
+
/** A scripted runner: results come from a queue per call; also tracks max in-flight calls. */
|
|
17
|
+
function scripted(script) {
|
|
18
|
+
let calls = 0;
|
|
19
|
+
let inFlight = 0;
|
|
20
|
+
let maxInFlight = 0;
|
|
21
|
+
const runner = async (args) => {
|
|
22
|
+
const index = calls++;
|
|
23
|
+
inFlight++;
|
|
24
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
25
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
26
|
+
inFlight--;
|
|
27
|
+
return script(args, index);
|
|
28
|
+
};
|
|
29
|
+
return { runner, stats: () => ({ calls, maxInFlight }) };
|
|
30
|
+
}
|
|
31
|
+
test('opt-in gate: without the env flag nothing is spawned and nothing is measured', async () => {
|
|
32
|
+
const fake = scripted(() => ({ ok: true, ms: 1, candidates: [] }));
|
|
33
|
+
const result = await measureIfOptedIn({}, fake.runner);
|
|
34
|
+
assert.equal(result.state, 'waiting_external_evidence');
|
|
35
|
+
assert.equal(fake.stats().calls, 0, 'runner must not be called without opt-in');
|
|
36
|
+
const wrong = await measureIfOptedIn({ [OPT_IN_ENV]: 'true' }, fake.runner);
|
|
37
|
+
assert.equal(wrong.state, 'waiting_external_evidence', 'only the exact value 1 opts in');
|
|
38
|
+
assert.equal(fake.stats().calls, 0);
|
|
39
|
+
});
|
|
40
|
+
test('opt-in gate: GOTRY_UAT_READONLY=1 runs the whole measurement', async () => {
|
|
41
|
+
const fake = scripted(() => ({ ok: true, ms: 5, candidates: [city()] }));
|
|
42
|
+
const result = await measureIfOptedIn({ [OPT_IN_ENV]: '1' }, fake.runner);
|
|
43
|
+
assert.equal(result.state, 'measured');
|
|
44
|
+
assert.ok(fake.stats().calls > 0);
|
|
45
|
+
});
|
|
46
|
+
test('percentile: nearest-rank, empty sample is null, single sample is itself', () => {
|
|
47
|
+
assert.equal(percentile([], 50), null);
|
|
48
|
+
assert.equal(percentile([7], 95), 7);
|
|
49
|
+
const sample = Array.from({ length: 20 }, (_, i) => (i + 1) * 10);
|
|
50
|
+
assert.equal(percentile(sample, 50), 100);
|
|
51
|
+
assert.equal(percentile(sample, 95), 190);
|
|
52
|
+
assert.equal(percentile([30, 10, 20], 50), 20, 'input order must not matter');
|
|
53
|
+
});
|
|
54
|
+
test('latencyStats counts failures by reason and keeps their timings in the sample', () => {
|
|
55
|
+
const stats = latencyStats([
|
|
56
|
+
{ ok: true, ms: 100, candidates: [] },
|
|
57
|
+
{ ok: false, ms: 900, reason: 'exit_1', detail: '' },
|
|
58
|
+
{ ok: false, ms: 50, reason: 'exit_1', detail: '' },
|
|
59
|
+
{ ok: false, ms: 60_000, reason: 'timeout', detail: '' },
|
|
60
|
+
]);
|
|
61
|
+
assert.equal(stats.calls, 4);
|
|
62
|
+
assert.equal(stats.errors, 3);
|
|
63
|
+
assert.deepEqual(stats.error_reasons, { exit_1: 2, timeout: 1 });
|
|
64
|
+
assert.equal(stats.max_ms, 60_000);
|
|
65
|
+
});
|
|
66
|
+
test('fieldPresence: empty strings, empty arrays and empty objects do not count as present', () => {
|
|
67
|
+
const presence = fieldPresence([
|
|
68
|
+
{ name: 'a', rating: 4.5, photos: [], hours: {}, note: '' },
|
|
69
|
+
{ name: 'b', rating: null },
|
|
70
|
+
undefined,
|
|
71
|
+
]);
|
|
72
|
+
assert.equal(presence.n, 2, 'undefined entries are not objects');
|
|
73
|
+
assert.deepEqual(presence.fields, { hours: '0/2', name: '2/2', note: '0/2', photos: '0/2', rating: '1/2' });
|
|
74
|
+
});
|
|
75
|
+
test('scrub: credential-looking material is redacted and the text is bounded', () => {
|
|
76
|
+
const scrubbed = scrub('401 Unauthorized: Bearer abc.def.ghi ticket=ST:12345 token: zzz api_key=k123 password hunter2');
|
|
77
|
+
assert.ok(!/abc\.def|12345|zzz|k123|hunter2/.test(scrubbed), `secret material survived: ${scrubbed}`);
|
|
78
|
+
assert.ok(scrubbed.length <= 160);
|
|
79
|
+
assert.ok(scrub('x'.repeat(1000)).length <= 160);
|
|
80
|
+
});
|
|
81
|
+
test('measure: aggregates a scripted run (types, place emptiness, presence, failures) deterministically', async () => {
|
|
82
|
+
const keywords = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
|
|
83
|
+
let n = 0;
|
|
84
|
+
const fake = scripted((args) => {
|
|
85
|
+
n++;
|
|
86
|
+
const placeOnly = args.includes('place');
|
|
87
|
+
if (placeOnly)
|
|
88
|
+
return { ok: true, ms: 10, candidates: [] };
|
|
89
|
+
if (n === 2)
|
|
90
|
+
return { ok: false, ms: 40, reason: 'exit_1', detail: 'boom' };
|
|
91
|
+
return { ok: true, ms: 20 + n, candidates: [city(), hotel(), hotel({ rating: 4.2 })] };
|
|
92
|
+
});
|
|
93
|
+
const fixedClock = () => new Date('2026-10-04T00:00:00.000Z');
|
|
94
|
+
const result = await measure(fake.runner, keywords, fixedClock);
|
|
95
|
+
assert.equal(result.measured_at, '2026-10-04T00:00:00.000Z');
|
|
96
|
+
assert.equal(result.sequential_mixed.calls, 7);
|
|
97
|
+
assert.equal(result.sequential_mixed.errors, 1);
|
|
98
|
+
assert.deepEqual(result.sequential_mixed.error_reasons, { exit_1: 1 });
|
|
99
|
+
assert.equal(result.sequential_place_only.errors, 0);
|
|
100
|
+
assert.equal(result.place_only_total_candidates, 0);
|
|
101
|
+
assert.equal(result.keywords_with_zero_place_results, '7/7');
|
|
102
|
+
assert.equal(result.place_object_field_presence.n, 0);
|
|
103
|
+
assert.deepEqual(Object.keys(result.mixed_candidates_by_type).sort(), ['city', 'hotel']);
|
|
104
|
+
assert.equal(result.mixed_candidates_by_type['city'], 6, 'one of seven mixed calls failed');
|
|
105
|
+
assert.equal(result.hotel_object_field_presence.fields['rating'], '6/12', 'half of the 12 hotel candidates carry a rating');
|
|
106
|
+
assert.equal(result.concurrent_mixed.concurrency, CONCURRENCY);
|
|
107
|
+
assert.equal(result.concurrent_mixed.calls, 7);
|
|
108
|
+
});
|
|
109
|
+
test('measure: the burst phase never exceeds the declared concurrency', async () => {
|
|
110
|
+
const keywords = Array.from({ length: 23 }, (_, i) => `k${i}`);
|
|
111
|
+
const fake = scripted(() => ({ ok: true, ms: 1, candidates: [] }));
|
|
112
|
+
await measure(fake.runner, keywords);
|
|
113
|
+
assert.ok(fake.stats().maxInFlight <= CONCURRENCY, `in-flight ${fake.stats().maxInFlight} exceeded ${CONCURRENCY}`);
|
|
114
|
+
assert.ok(fake.stats().maxInFlight > 1, 'burst phase must actually run concurrently');
|
|
115
|
+
assert.equal(fake.stats().calls, keywords.length * 3, 'mixed + place-only + burst');
|
|
116
|
+
});
|
|
117
|
+
let failed = 0;
|
|
118
|
+
for (const { name, run } of tests) {
|
|
119
|
+
try {
|
|
120
|
+
await run();
|
|
121
|
+
console.log(` ok ${name}`);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
failed++;
|
|
125
|
+
console.error(` FAIL ${name}`);
|
|
126
|
+
console.error(error);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (failed > 0) {
|
|
130
|
+
console.error(`${failed} anything-path-measure test(s) failed`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
console.log(`anything-path-measure tests: ${tests.length} passed, 0 failed (offline: real hbcli runner never used)`);
|