agentwrangler 0.1.0
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/LICENSE +191 -0
- package/README.md +116 -0
- package/dist/apply/jobs.js +429 -0
- package/dist/apply/open-terminal-child.mjs +98 -0
- package/dist/apply/open-terminal.js +221 -0
- package/dist/apply/settings-gen.js +35 -0
- package/dist/cli/agentwrangler.js +18 -0
- package/dist/daemon/config.js +51 -0
- package/dist/daemon/http.js +258 -0
- package/dist/daemon/index.js +372 -0
- package/dist/daemon/outcomes-pass.js +82 -0
- package/dist/daemon/readiness.js +15 -0
- package/dist/daemon/router.js +756 -0
- package/dist/daemon/static.js +146 -0
- package/dist/db/migrate.js +72 -0
- package/dist/db/migrations/001_observe.sql +196 -0
- package/dist/db/migrations/002_indexes.sql +6 -0
- package/dist/db/migrations/003_context_inventory_history.sql +20 -0
- package/dist/db/migrations/004_apply_jobs.sql +17 -0
- package/dist/db/migrations/005_tool_event_metadata.sql +17 -0
- package/dist/db/migrations/006_d7_query_indexes.sql +9 -0
- package/dist/db/migrations/007_work_item_branch_keys.sql +11 -0
- package/dist/db/migrations/008_thinking_tokens.sql +1 -0
- package/dist/db/migrations/009_user_turn_count.sql +1 -0
- package/dist/db/migrations/010_workspace_cwd.sql +1 -0
- package/dist/db/migrations/011_reports.sql +1 -0
- package/dist/db/migrations/012_reconcile_indexes.sql +2 -0
- package/dist/db/migrations/013_friction_fields.sql +5 -0
- package/dist/db/migrations/014_session_churn.sql +11 -0
- package/dist/db/migrations/015_gap_aggregates.sql +6 -0
- package/dist/db/open.js +30 -0
- package/dist/detector/benchmark-anchors.js +36 -0
- package/dist/detector/calibration.js +302 -0
- package/dist/detector/context-history-retention.js +312 -0
- package/dist/detector/context-probe.js +574 -0
- package/dist/detector/d1-source-identity.js +25 -0
- package/dist/detector/detectors/d10_catalog_footprint.js +146 -0
- package/dist/detector/detectors/d1_ctx_always_loaded.js +203 -0
- package/dist/detector/detectors/d2_session_long_full_context.js +119 -0
- package/dist/detector/detectors/d4_model_mismatch.js +258 -0
- package/dist/detector/detectors/d5_limit_burn_forecast.js +138 -0
- package/dist/detector/detectors/d6_tool_result_bloat.js +301 -0
- package/dist/detector/detectors/d7_loop_retry_waste.js +345 -0
- package/dist/detector/detectors/d8_cache_write_churn.js +201 -0
- package/dist/detector/detectors/d9_idle_background_session.js +101 -0
- package/dist/detector/engine.js +88 -0
- package/dist/detector/index.js +17 -0
- package/dist/detector/measurement.js +426 -0
- package/dist/detector/practice-registry.js +259 -0
- package/dist/detector/registry.js +32 -0
- package/dist/detector/savings.js +249 -0
- package/dist/detector/types.js +14 -0
- package/dist/evidence/common/approved-input.js +632 -0
- package/dist/evidence/common/boundary.js +84 -0
- package/dist/evidence/common/canonical.js +55 -0
- package/dist/evidence/common/redaction.js +321 -0
- package/dist/evidence/common/sqlite.js +25 -0
- package/dist/evidence/common/state.js +29 -0
- package/dist/evidence/cond1/cli.js +289 -0
- package/dist/evidence/cond1/packet.js +407 -0
- package/dist/evidence/cond1/prepare.js +295 -0
- package/dist/evidence/cond1/score.js +349 -0
- package/dist/evidence/cond1/types.js +1 -0
- package/dist/evidence/create-approval.js +365 -0
- package/dist/evidence/create-scratch.js +542 -0
- package/dist/evidence/d7/cli.js +113 -0
- package/dist/evidence/d7/measure.js +193 -0
- package/dist/evidence/d7/types.js +1 -0
- package/dist/evidence/discover-approval.js +492 -0
- package/dist/evidence/g2/adjudicate.js +20 -0
- package/dist/evidence/g2/cli.js +207 -0
- package/dist/evidence/g2/kappa.js +39 -0
- package/dist/evidence/g2/pipeline.js +92 -0
- package/dist/evidence/g2/store.js +14 -0
- package/dist/evidence/github/client.js +1 -0
- package/dist/evidence/github/gh-cli-client.js +301 -0
- package/dist/evidence/r3/cli.js +209 -0
- package/dist/evidence/r3/evaluate.js +417 -0
- package/dist/evidence/r3/packet.js +162 -0
- package/dist/evidence/r3/prepare.js +405 -0
- package/dist/evidence/r3/score.js +341 -0
- package/dist/evidence/r3/transcript.js +155 -0
- package/dist/evidence/r3/types.js +4 -0
- package/dist/hook/context-budget-hook.mjs +138 -0
- package/dist/hook/danger-guard-denylist.json +27 -0
- package/dist/hook/danger-guard-hook.mjs +167 -0
- package/dist/hook/install.js +0 -0
- package/dist/hook/limit-burn-hook.mjs +127 -0
- package/dist/hook/loop-guard-hook.mjs +104 -0
- package/dist/hook/precompact-checkpoint-hook.mjs +123 -0
- package/dist/ingest/churn-collector.js +122 -0
- package/dist/ingest/detector-hook.js +52 -0
- package/dist/ingest/discovery.js +207 -0
- package/dist/ingest/health.js +43 -0
- package/dist/ingest/index.js +28 -0
- package/dist/ingest/ingestor.js +509 -0
- package/dist/ingest/parser.js +344 -0
- package/dist/ingest/pricing.js +153 -0
- package/dist/ingest/reconcile.js +52 -0
- package/dist/ingest/tail.js +152 -0
- package/dist/ingest/types.js +24 -0
- package/dist/ingest/workspace-mapping.js +114 -0
- package/dist/oauth/anthropic-api-key.js +88 -0
- package/dist/oauth/count-tokens.js +86 -0
- package/dist/oauth/credentials.js +171 -0
- package/dist/oauth/judge-g2-client.js +154 -0
- package/dist/oauth/usage.js +167 -0
- package/dist/outcomes/branch-key.js +49 -0
- package/dist/outcomes/conclusions.js +45 -0
- package/dist/outcomes/derive.js +94 -0
- package/dist/outcomes/finding-extractors.js +131 -0
- package/dist/outcomes/findings.js +237 -0
- package/dist/outcomes/github/client.js +367 -0
- package/dist/outcomes/github/credential.js +195 -0
- package/dist/outcomes/github/gh-cli-client.js +340 -0
- package/dist/outcomes/linker.js +486 -0
- package/dist/outcomes/pool.js +24 -0
- package/dist/outcomes/sync.js +276 -0
- package/dist/query/api/agents-liveness.js +182 -0
- package/dist/query/api/burn-status.js +50 -0
- package/dist/query/api/context-budget.js +114 -0
- package/dist/query/api/context-composition.js +67 -0
- package/dist/query/api/cost-per-success.js +104 -0
- package/dist/query/api/delivery.js +92 -0
- package/dist/query/api/effectiveness.js +254 -0
- package/dist/query/api/efficiency-headroom.js +74 -0
- package/dist/query/api/headroom-trend.js +105 -0
- package/dist/query/api/hook-config.js +75 -0
- package/dist/query/api/hook-install.js +8 -0
- package/dist/query/api/hot-sessions.js +17 -0
- package/dist/query/api/idle-sessions.js +52 -0
- package/dist/query/api/index.js +40 -0
- package/dist/query/api/loop-guard.js +90 -0
- package/dist/query/api/offload-share.js +41 -0
- package/dist/query/api/outcomes.js +218 -0
- package/dist/query/api/overview.js +535 -0
- package/dist/query/api/rec-prompt.js +138 -0
- package/dist/query/api/recommendations-ledger.js +111 -0
- package/dist/query/api/recommendations.js +514 -0
- package/dist/query/api/reports.js +78 -0
- package/dist/query/api/self-churn.js +77 -0
- package/dist/query/api/self-percentiles.js +109 -0
- package/dist/query/api/session-drivers.js +153 -0
- package/dist/query/api/settings.js +85 -0
- package/dist/query/api/spend-flavor.js +234 -0
- package/dist/query/api/trends.js +155 -0
- package/dist/query/cap-weighted.js +119 -0
- package/dist/query/db-context.js +42 -0
- package/dist/query/envelope.js +71 -0
- package/dist/query/forecast.js +191 -0
- package/dist/query/settings-store.js +441 -0
- package/dist/query/spend.js +171 -0
- package/dist/query/trends.js +194 -0
- package/dist/ui/assets/index-DnRKgc21.css +1 -0
- package/dist/ui/assets/index-h1Q1wWq5.js +168 -0
- package/dist/ui/index.html +39 -0
- package/package.json +59 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { D7_FORWARD_COVERAGE_RUNNER_VERSION, } from "./types.js";
|
|
2
|
+
const WINDOW_DAYS = 30;
|
|
3
|
+
const THRESHOLD = 0.8;
|
|
4
|
+
function fail(code) {
|
|
5
|
+
throw new Error(code);
|
|
6
|
+
}
|
|
7
|
+
function ratio(numerator, denominator) {
|
|
8
|
+
return denominator === 0 ? null : numerator / denominator;
|
|
9
|
+
}
|
|
10
|
+
function asCount(value) {
|
|
11
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
|
|
12
|
+
fail("d7_aggregate_invalid");
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
function cohort(row) {
|
|
16
|
+
const sessionsN = asCount(row.sessionsN);
|
|
17
|
+
const eventsN = asCount(row.eventsN);
|
|
18
|
+
const coveredSessionsN = asCount(row.coveredSessionsN);
|
|
19
|
+
const coveredEventsN = asCount(row.coveredEventsN);
|
|
20
|
+
return {
|
|
21
|
+
cohort: row.cohort,
|
|
22
|
+
reconciledToolUsingSessionsN: sessionsN,
|
|
23
|
+
toolEventsN: eventsN,
|
|
24
|
+
coveredSessionsN,
|
|
25
|
+
coveredEventsN,
|
|
26
|
+
sessionCoverage: ratio(coveredSessionsN, sessionsN),
|
|
27
|
+
eventCoverage: ratio(coveredEventsN, eventsN),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function assertD7AsOf(value) {
|
|
31
|
+
if (!Number.isFinite(Date.parse(value)) ||
|
|
32
|
+
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value) ||
|
|
33
|
+
new Date(value).toISOString() !== value)
|
|
34
|
+
fail("d7_as_of_invalid");
|
|
35
|
+
}
|
|
36
|
+
/** Aggregate the approved query-only snapshot without returning private identifiers. */
|
|
37
|
+
export function measureD7ForwardCoverage(input) {
|
|
38
|
+
if (input.windowDays !== WINDOW_DAYS)
|
|
39
|
+
fail("d7_window_days_invalid");
|
|
40
|
+
assertD7AsOf(input.asOf);
|
|
41
|
+
if (!/^[0-9a-f]{40}$/u.test(input.sourceCommit))
|
|
42
|
+
fail("d7_source_commit_invalid");
|
|
43
|
+
const from = new Date(Date.parse(input.asOf) - WINDOW_DAYS * 86_400_000).toISOString();
|
|
44
|
+
const aliases = input.repositories.map((repository) => [repository.workspaceId, repository.reportAlias]);
|
|
45
|
+
if (aliases.length === 0 ||
|
|
46
|
+
aliases.some(([workspaceId, reportAlias]) => workspaceId.length === 0 || !/^repo-[0-9]{3,}$/u.test(reportAlias)) ||
|
|
47
|
+
new Set(aliases.map(([workspaceId]) => workspaceId)).size !== aliases.length ||
|
|
48
|
+
new Set(aliases.map(([, reportAlias]) => reportAlias)).size !== aliases.length)
|
|
49
|
+
fail("d7_repository_map_invalid");
|
|
50
|
+
const values = aliases.map(() => "(?, ?)").join(", ");
|
|
51
|
+
const aliasArgs = aliases.flat();
|
|
52
|
+
const base = `
|
|
53
|
+
WITH approved(workspace_id, report_alias) AS (VALUES ${values}),
|
|
54
|
+
params(from_ts, as_of_ts) AS (VALUES (?, ?)),
|
|
55
|
+
metadata AS (
|
|
56
|
+
SELECT event_id, COUNT(*) AS metadata_rows,
|
|
57
|
+
MAX(file_path_hash) AS file_path_hash, MAX(owner_message_id) AS owner_message_id
|
|
58
|
+
FROM tool_event_metadata GROUP BY event_id
|
|
59
|
+
),
|
|
60
|
+
owners AS (
|
|
61
|
+
SELECT message_id, COUNT(*) AS owner_rows, MAX(session_id) AS owner_session_id,
|
|
62
|
+
MAX(workspace_id) AS owner_workspace_id, MAX(ts) AS owner_ts,
|
|
63
|
+
MAX(provisional) AS owner_provisional
|
|
64
|
+
FROM turns GROUP BY message_id
|
|
65
|
+
),
|
|
66
|
+
candidates AS (
|
|
67
|
+
SELECT e.event_id, e.session_id, e.ts AS event_ts, e.input_hash, e.exit_class,
|
|
68
|
+
s.workspace_id AS session_workspace_id, a.report_alias,
|
|
69
|
+
COALESCE(m.metadata_rows, 0) AS metadata_rows, m.file_path_hash, m.owner_message_id,
|
|
70
|
+
COALESCE(t.owner_rows, 0) AS owner_rows, t.owner_session_id,
|
|
71
|
+
t.owner_workspace_id, t.owner_ts, t.owner_provisional,
|
|
72
|
+
p.from_ts, p.as_of_ts
|
|
73
|
+
FROM tool_events e
|
|
74
|
+
JOIN sessions s ON s.session_id = e.session_id AND s.state = 'RECONCILED'
|
|
75
|
+
LEFT JOIN approved a ON a.workspace_id = s.workspace_id
|
|
76
|
+
LEFT JOIN metadata m ON m.event_id = e.event_id
|
|
77
|
+
LEFT JOIN owners t ON t.message_id = m.owner_message_id
|
|
78
|
+
CROSS JOIN params p
|
|
79
|
+
),
|
|
80
|
+
classified AS (
|
|
81
|
+
SELECT *,
|
|
82
|
+
CASE WHEN strftime('%s', event_ts) IS NULL THEN 1 ELSE 0 END AS event_malformed,
|
|
83
|
+
CASE WHEN strftime('%s', event_ts) IS NOT NULL
|
|
84
|
+
AND julianday(event_ts) >= julianday(from_ts)
|
|
85
|
+
AND julianday(event_ts) < julianday(as_of_ts) THEN 1 ELSE 0 END AS in_window,
|
|
86
|
+
CASE WHEN metadata_rows = 1 AND owner_rows = 1
|
|
87
|
+
AND owner_session_id = session_id AND owner_workspace_id = session_workspace_id
|
|
88
|
+
AND owner_provisional = 0 AND strftime('%s', owner_ts) IS NOT NULL
|
|
89
|
+
AND julianday(owner_ts) >= julianday(from_ts)
|
|
90
|
+
AND julianday(owner_ts) < julianday(as_of_ts) THEN 1 ELSE 0 END AS owner_eligible
|
|
91
|
+
FROM candidates
|
|
92
|
+
),
|
|
93
|
+
eligible AS (
|
|
94
|
+
SELECT *, in_window AS denominator_event,
|
|
95
|
+
CASE WHEN in_window = 1 AND metadata_rows = 1 AND owner_eligible = 1
|
|
96
|
+
AND report_alias IS NOT NULL AND (input_hash IS NOT NULL OR exit_class IS NOT NULL OR file_path_hash IS NOT NULL)
|
|
97
|
+
THEN 1 ELSE 0 END AS covered_event
|
|
98
|
+
FROM classified
|
|
99
|
+
)`;
|
|
100
|
+
const aggregateSql = `${base}
|
|
101
|
+
SELECT
|
|
102
|
+
COUNT(DISTINCT CASE WHEN denominator_event = 1 THEN session_id END) AS sessionsN,
|
|
103
|
+
COALESCE(SUM(denominator_event), 0) AS eventsN,
|
|
104
|
+
COUNT(DISTINCT CASE WHEN covered_event = 1 THEN session_id END) AS coveredSessionsN,
|
|
105
|
+
COALESCE(SUM(covered_event), 0) AS coveredEventsN,
|
|
106
|
+
COALESCE(SUM(CASE WHEN denominator_event = 1 AND metadata_rows = 0 THEN 1 ELSE 0 END), 0) AS missingMetadataN,
|
|
107
|
+
COALESCE(SUM(CASE WHEN denominator_event = 1 AND metadata_rows = 1 AND owner_eligible = 0 THEN 1 ELSE 0 END), 0) AS missingOwnerTurnN,
|
|
108
|
+
COALESCE(SUM(CASE WHEN denominator_event = 1 AND metadata_rows = 1 AND owner_eligible = 1 AND report_alias IS NOT NULL
|
|
109
|
+
AND input_hash IS NULL AND exit_class IS NULL AND file_path_hash IS NULL THEN 1 ELSE 0 END), 0) AS noSignalInputN,
|
|
110
|
+
COALESCE(SUM(CASE
|
|
111
|
+
WHEN event_malformed = 1 THEN 1
|
|
112
|
+
WHEN denominator_event = 1 AND metadata_rows = 1 AND owner_rows = 1
|
|
113
|
+
AND strftime('%s', owner_ts) IS NULL THEN 1 ELSE 0 END), 0) AS malformedTimestampN,
|
|
114
|
+
COALESCE(SUM(CASE WHEN denominator_event = 1 AND (
|
|
115
|
+
report_alias IS NULL OR metadata_rows > 1 OR owner_rows > 1 OR
|
|
116
|
+
(metadata_rows = 1 AND owner_rows = 1 AND
|
|
117
|
+
(owner_session_id <> session_id OR owner_workspace_id <> session_workspace_id))
|
|
118
|
+
) THEN 1 ELSE 0 END), 0) AS invariantFailureN
|
|
119
|
+
FROM eligible`;
|
|
120
|
+
const args = [...aliasArgs, from, input.asOf];
|
|
121
|
+
const aggregate = input.db.prepare(aggregateSql).get(...args);
|
|
122
|
+
const daySql = `${base}
|
|
123
|
+
SELECT strftime('%Y-%m-%d', event_ts) AS cohort, COUNT(DISTINCT session_id) AS sessionsN,
|
|
124
|
+
COUNT(*) AS eventsN, COUNT(DISTINCT CASE WHEN covered_event = 1 THEN session_id END) AS coveredSessionsN,
|
|
125
|
+
SUM(covered_event) AS coveredEventsN
|
|
126
|
+
FROM eligible WHERE denominator_event = 1 GROUP BY strftime('%Y-%m-%d', event_ts) ORDER BY cohort`;
|
|
127
|
+
const workspaceSql = `${base}
|
|
128
|
+
SELECT report_alias AS cohort, COUNT(DISTINCT session_id) AS sessionsN, COUNT(*) AS eventsN,
|
|
129
|
+
COUNT(DISTINCT CASE WHEN covered_event = 1 THEN session_id END) AS coveredSessionsN,
|
|
130
|
+
SUM(covered_event) AS coveredEventsN
|
|
131
|
+
FROM eligible WHERE denominator_event = 1 AND report_alias IS NOT NULL GROUP BY report_alias ORDER BY report_alias`;
|
|
132
|
+
const aggregateCounts = {
|
|
133
|
+
sessionsN: asCount(aggregate.sessionsN),
|
|
134
|
+
eventsN: asCount(aggregate.eventsN),
|
|
135
|
+
coveredSessionsN: asCount(aggregate.coveredSessionsN),
|
|
136
|
+
coveredEventsN: asCount(aggregate.coveredEventsN),
|
|
137
|
+
missingMetadataN: asCount(aggregate.missingMetadataN),
|
|
138
|
+
missingOwnerTurnN: asCount(aggregate.missingOwnerTurnN),
|
|
139
|
+
noSignalInputN: asCount(aggregate.noSignalInputN),
|
|
140
|
+
malformedTimestampN: asCount(aggregate.malformedTimestampN),
|
|
141
|
+
invariantFailureN: asCount(aggregate.invariantFailureN),
|
|
142
|
+
};
|
|
143
|
+
const sessionCoverage = ratio(aggregateCounts.coveredSessionsN, aggregateCounts.sessionsN);
|
|
144
|
+
const eventCoverage = ratio(aggregateCounts.coveredEventsN, aggregateCounts.eventsN);
|
|
145
|
+
const incomplete = aggregateCounts.eventsN === 0 ||
|
|
146
|
+
aggregateCounts.malformedTimestampN > 0 ||
|
|
147
|
+
aggregateCounts.invariantFailureN > 0;
|
|
148
|
+
const sufficient = !incomplete &&
|
|
149
|
+
aggregateCounts.sessionsN >= 20 &&
|
|
150
|
+
sessionCoverage !== null &&
|
|
151
|
+
eventCoverage !== null &&
|
|
152
|
+
sessionCoverage >= THRESHOLD &&
|
|
153
|
+
eventCoverage >= THRESHOLD;
|
|
154
|
+
const status = incomplete ? "DATA_INSUFFICIENT" : sufficient ? "SUFFICIENT" : "INSUFFICIENT";
|
|
155
|
+
return {
|
|
156
|
+
campaign: "D7_FORWARD_COVERAGE",
|
|
157
|
+
status,
|
|
158
|
+
identity: {
|
|
159
|
+
sourceCommit: input.sourceCommit,
|
|
160
|
+
runnerVersion: D7_FORWARD_COVERAGE_RUNNER_VERSION,
|
|
161
|
+
scratchDbSha256: input.scratchDbSha256,
|
|
162
|
+
repoMapSha256: input.repoMapSha256,
|
|
163
|
+
asOf: input.asOf,
|
|
164
|
+
windowDays: 30,
|
|
165
|
+
},
|
|
166
|
+
denominator: {
|
|
167
|
+
reconciledToolUsingSessionsN: aggregateCounts.sessionsN,
|
|
168
|
+
toolEventsN: aggregateCounts.eventsN,
|
|
169
|
+
},
|
|
170
|
+
covered: {
|
|
171
|
+
sessionsN: aggregateCounts.coveredSessionsN,
|
|
172
|
+
eventsN: aggregateCounts.coveredEventsN,
|
|
173
|
+
sessionCoverage,
|
|
174
|
+
eventCoverage,
|
|
175
|
+
},
|
|
176
|
+
exclusions: {
|
|
177
|
+
missingMetadataN: aggregateCounts.missingMetadataN,
|
|
178
|
+
missingOwnerTurnN: aggregateCounts.missingOwnerTurnN,
|
|
179
|
+
noSignalInputN: aggregateCounts.noSignalInputN,
|
|
180
|
+
malformedTimestampN: aggregateCounts.malformedTimestampN,
|
|
181
|
+
invariantFailureN: aggregateCounts.invariantFailureN,
|
|
182
|
+
},
|
|
183
|
+
byUtcDay: input.db.prepare(daySql).all(...args).map(cohort),
|
|
184
|
+
byWorkspaceAlias: input.db.prepare(workspaceSql).all(...args).map(cohort),
|
|
185
|
+
decision: {
|
|
186
|
+
minimumSessions: 20,
|
|
187
|
+
sessionCoverageThreshold: 0.8,
|
|
188
|
+
eventCoverageThreshold: 0.8,
|
|
189
|
+
replayProofEligible: status === "INSUFFICIENT",
|
|
190
|
+
},
|
|
191
|
+
privacy: { sessionIdN: 0, workspaceIdN: 0, eventIdN: 0, hashN: 0, pathN: 0, transcriptN: 0 },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const D7_FORWARD_COVERAGE_RUNNER_VERSION = "d7-forward-coverage-v1";
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
|
+
import { EvidenceBoundaryError, readFileIdentity } from "./common/boundary.js";
|
|
5
|
+
import { canonicalJson } from "./common/canonical.js";
|
|
6
|
+
import { openQueryOnlyDb } from "./common/sqlite.js";
|
|
7
|
+
import { createScratchApprovalCandidate } from "./create-approval.js";
|
|
8
|
+
const REPOSITORY_ROOT = path.resolve(fileURLToPath(new URL("../../", import.meta.url)));
|
|
9
|
+
export const PATH_SLUG_SUGGESTION_VERSION = "claude-path-slug-suggestion-v1";
|
|
10
|
+
const DEFAULT_MAX_ENTRIES = 20_000;
|
|
11
|
+
const MAX_CONFIG_BYTES = 1024 * 1024;
|
|
12
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
13
|
+
".git",
|
|
14
|
+
".next",
|
|
15
|
+
"build",
|
|
16
|
+
"coverage",
|
|
17
|
+
"dist",
|
|
18
|
+
"node_modules",
|
|
19
|
+
"out",
|
|
20
|
+
]);
|
|
21
|
+
function refuse(code) {
|
|
22
|
+
throw new EvidenceBoundaryError(code);
|
|
23
|
+
}
|
|
24
|
+
/** Experimental, collision-prone heuristic for confirmation-only proposal matching. */
|
|
25
|
+
export function encodeCheckoutRootSuggestion(canonicalCheckoutRoot) {
|
|
26
|
+
return canonicalCheckoutRoot.replace(/[^A-Za-z0-9-]/gu, "-");
|
|
27
|
+
}
|
|
28
|
+
function binaryCompare(left, right) {
|
|
29
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
function increment(counts, reason) {
|
|
32
|
+
counts[reason] = (counts[reason] ?? 0) + 1;
|
|
33
|
+
}
|
|
34
|
+
function exactIdentity(left, right) {
|
|
35
|
+
return left.device === right.device && left.inode === right.inode && left.size === right.size;
|
|
36
|
+
}
|
|
37
|
+
function sourceMetadataUnchanged(sourceDbPath, expectedIdentity, expectedRows) {
|
|
38
|
+
try {
|
|
39
|
+
return (exactIdentity(expectedIdentity, readFileIdentity(sourceDbPath)) &&
|
|
40
|
+
canonicalJson(expectedRows) === canonicalJson(readWorkspaces(sourceDbPath)));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function readWorkspaces(sourceDbPath) {
|
|
47
|
+
const db = openQueryOnlyDb(sourceDbPath);
|
|
48
|
+
try {
|
|
49
|
+
const rows = db
|
|
50
|
+
.prepare(`SELECT workspace_id, project_slug, repo_path, repo_owner, repo_name
|
|
51
|
+
FROM workspaces
|
|
52
|
+
ORDER BY workspace_id COLLATE BINARY`)
|
|
53
|
+
.all();
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
return rows.map((row) => {
|
|
56
|
+
if (typeof row.workspace_id !== "string" ||
|
|
57
|
+
row.workspace_id.length === 0 ||
|
|
58
|
+
typeof row.project_slug !== "string" ||
|
|
59
|
+
row.project_slug.length === 0 ||
|
|
60
|
+
(row.repo_path !== null && typeof row.repo_path !== "string")) {
|
|
61
|
+
refuse("discovery_workspace_row_invalid");
|
|
62
|
+
}
|
|
63
|
+
if (seen.has(row.workspace_id))
|
|
64
|
+
refuse("discovery_workspace_duplicate");
|
|
65
|
+
seen.add(row.workspace_id);
|
|
66
|
+
const ownerMissing = row.repo_owner === null;
|
|
67
|
+
const repoMissing = row.repo_name === null;
|
|
68
|
+
if (ownerMissing !== repoMissing)
|
|
69
|
+
refuse("discovery_partial_repository_mapping");
|
|
70
|
+
if ((!ownerMissing && (typeof row.repo_owner !== "string" || row.repo_owner.length === 0)) ||
|
|
71
|
+
(!repoMissing && (typeof row.repo_name !== "string" || row.repo_name.length === 0))) {
|
|
72
|
+
refuse("discovery_repository_mapping_invalid");
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
workspaceId: row.workspace_id,
|
|
76
|
+
projectSlug: row.project_slug,
|
|
77
|
+
repoPath: row.repo_path,
|
|
78
|
+
owner: row.repo_owner,
|
|
79
|
+
repo: row.repo_name,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
db.close();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function gitConfigPath(checkoutRoot) {
|
|
88
|
+
const dotGit = path.join(checkoutRoot, ".git");
|
|
89
|
+
const stat = fs.lstatSync(dotGit);
|
|
90
|
+
if (stat.isSymbolicLink())
|
|
91
|
+
return undefined;
|
|
92
|
+
if (stat.isDirectory())
|
|
93
|
+
return path.join(dotGit, "config");
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
function parseGitHubOrigin(value) {
|
|
97
|
+
let owner;
|
|
98
|
+
let repo;
|
|
99
|
+
const scp = /^git@github\.com:([^/]+)\/(.+)$/iu.exec(value);
|
|
100
|
+
if (scp !== null) {
|
|
101
|
+
owner = scp[1];
|
|
102
|
+
repo = scp[2];
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
let parsed;
|
|
106
|
+
try {
|
|
107
|
+
parsed = new URL(value);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return { reason: "origin_malformed" };
|
|
111
|
+
}
|
|
112
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "ssh:") {
|
|
113
|
+
return { reason: "origin_unsupported" };
|
|
114
|
+
}
|
|
115
|
+
if (parsed.hostname.toLowerCase() !== "github.com" || parsed.port !== "") {
|
|
116
|
+
return { reason: "origin_non_github" };
|
|
117
|
+
}
|
|
118
|
+
if (parsed.search !== "" || parsed.hash !== "")
|
|
119
|
+
return { reason: "origin_malformed" };
|
|
120
|
+
if (parsed.password !== "" || (parsed.protocol === "https:" && parsed.username !== "")) {
|
|
121
|
+
return { reason: "origin_credentials_present" };
|
|
122
|
+
}
|
|
123
|
+
if (parsed.protocol === "ssh:" && parsed.username !== "git") {
|
|
124
|
+
return { reason: "origin_malformed" };
|
|
125
|
+
}
|
|
126
|
+
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
127
|
+
if (segments.length !== 2)
|
|
128
|
+
return { reason: "origin_malformed" };
|
|
129
|
+
[owner, repo] = segments;
|
|
130
|
+
}
|
|
131
|
+
repo = repo?.replace(/\.git$/u, "");
|
|
132
|
+
const component = /^[A-Za-z0-9_.-]+$/u;
|
|
133
|
+
if (owner === undefined ||
|
|
134
|
+
repo === undefined ||
|
|
135
|
+
!component.test(owner) ||
|
|
136
|
+
!component.test(repo) ||
|
|
137
|
+
owner === "." ||
|
|
138
|
+
owner === ".." ||
|
|
139
|
+
repo === "." ||
|
|
140
|
+
repo === "..") {
|
|
141
|
+
return { reason: "origin_malformed" };
|
|
142
|
+
}
|
|
143
|
+
return { identity: { owner, repo } };
|
|
144
|
+
}
|
|
145
|
+
function readLocalOrigin(checkoutRoot) {
|
|
146
|
+
let configPath;
|
|
147
|
+
let descriptor;
|
|
148
|
+
let configBytes;
|
|
149
|
+
try {
|
|
150
|
+
configPath = gitConfigPath(checkoutRoot);
|
|
151
|
+
if (configPath === undefined)
|
|
152
|
+
return { reason: "git_metadata_unsafe" };
|
|
153
|
+
const unresolvedStat = fs.lstatSync(configPath);
|
|
154
|
+
if (!unresolvedStat.isFile() || unresolvedStat.isSymbolicLink()) {
|
|
155
|
+
return { reason: "git_config_unsafe" };
|
|
156
|
+
}
|
|
157
|
+
const resolvedConfig = fs.realpathSync.native(configPath);
|
|
158
|
+
const relative = path.relative(checkoutRoot, resolvedConfig);
|
|
159
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
160
|
+
return { reason: "git_config_unsafe" };
|
|
161
|
+
}
|
|
162
|
+
const pathStat = fs.lstatSync(resolvedConfig, { bigint: true });
|
|
163
|
+
if (!pathStat.isFile() ||
|
|
164
|
+
pathStat.isSymbolicLink() ||
|
|
165
|
+
pathStat.size > BigInt(MAX_CONFIG_BYTES)) {
|
|
166
|
+
return { reason: "git_config_unsafe" };
|
|
167
|
+
}
|
|
168
|
+
descriptor = fs.openSync(configPath, fs.constants.O_RDONLY);
|
|
169
|
+
const before = fs.fstatSync(descriptor, { bigint: true });
|
|
170
|
+
if (!before.isFile() ||
|
|
171
|
+
before.dev !== pathStat.dev ||
|
|
172
|
+
before.ino === 0n ||
|
|
173
|
+
before.ino !== pathStat.ino ||
|
|
174
|
+
before.size !== pathStat.size) {
|
|
175
|
+
throw new Error("git_config_identity_changed");
|
|
176
|
+
}
|
|
177
|
+
configBytes = fs.readFileSync(descriptor, "utf8");
|
|
178
|
+
const after = fs.fstatSync(descriptor, { bigint: true });
|
|
179
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) {
|
|
180
|
+
throw new Error("git_config_identity_changed");
|
|
181
|
+
}
|
|
182
|
+
fs.closeSync(descriptor);
|
|
183
|
+
descriptor = undefined;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
if (descriptor !== undefined) {
|
|
187
|
+
try {
|
|
188
|
+
fs.closeSync(descriptor);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
// The aggregate refusal reason remains the same.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { reason: "git_metadata_unreadable" };
|
|
195
|
+
}
|
|
196
|
+
let section = "";
|
|
197
|
+
const origins = [];
|
|
198
|
+
for (const rawLine of configBytes.split(/\r?\n/u)) {
|
|
199
|
+
const line = rawLine.trim();
|
|
200
|
+
const heading = /^\[([^\]]+)\]$/u.exec(line);
|
|
201
|
+
if (heading?.[1] !== undefined) {
|
|
202
|
+
section = heading[1].trim().toLowerCase();
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (section !== 'remote "origin"')
|
|
206
|
+
continue;
|
|
207
|
+
const value = /^url\s*=\s*(.+)$/iu.exec(line)?.[1];
|
|
208
|
+
if (value !== undefined)
|
|
209
|
+
origins.push(value.trim());
|
|
210
|
+
}
|
|
211
|
+
if (origins.length === 0)
|
|
212
|
+
return { reason: "origin_missing" };
|
|
213
|
+
if (origins.length !== 1)
|
|
214
|
+
return { reason: "origin_ambiguous" };
|
|
215
|
+
return parseGitHubOrigin(origins[0]);
|
|
216
|
+
}
|
|
217
|
+
function enumerateLocalRepositories(searchRoots, maxEntries, beforeVisitDirectory) {
|
|
218
|
+
if (searchRoots.length === 0)
|
|
219
|
+
refuse("discovery_search_root_required");
|
|
220
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1)
|
|
221
|
+
refuse("discovery_max_entries_invalid");
|
|
222
|
+
const pending = [];
|
|
223
|
+
const roots = new Set();
|
|
224
|
+
for (const candidate of searchRoots) {
|
|
225
|
+
let resolved;
|
|
226
|
+
try {
|
|
227
|
+
resolved = fs.realpathSync.native(candidate);
|
|
228
|
+
const stat = fs.lstatSync(candidate);
|
|
229
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
230
|
+
refuse("discovery_search_root_unsafe");
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error instanceof EvidenceBoundaryError)
|
|
234
|
+
throw error;
|
|
235
|
+
refuse("discovery_search_root_unreadable");
|
|
236
|
+
}
|
|
237
|
+
if (roots.has(resolved))
|
|
238
|
+
refuse("discovery_search_root_duplicate");
|
|
239
|
+
roots.add(resolved);
|
|
240
|
+
pending.push(resolved);
|
|
241
|
+
}
|
|
242
|
+
const orderedRoots = [...roots].sort(binaryCompare);
|
|
243
|
+
for (let index = 0; index < orderedRoots.length; index += 1) {
|
|
244
|
+
for (let other = index + 1; other < orderedRoots.length; other += 1) {
|
|
245
|
+
const relative = path.relative(orderedRoots[index], orderedRoots[other]);
|
|
246
|
+
if (relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== "..") {
|
|
247
|
+
refuse("discovery_search_roots_overlap");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
pending.sort(binaryCompare).reverse();
|
|
252
|
+
const repositories = [];
|
|
253
|
+
const visited = new Set();
|
|
254
|
+
let visitedEntries = 0;
|
|
255
|
+
while (pending.length > 0) {
|
|
256
|
+
const queued = pending.pop();
|
|
257
|
+
beforeVisitDirectory?.(queued);
|
|
258
|
+
let current;
|
|
259
|
+
try {
|
|
260
|
+
const queuedStat = fs.lstatSync(queued, { bigint: true });
|
|
261
|
+
if (!queuedStat.isDirectory() || queuedStat.isSymbolicLink())
|
|
262
|
+
continue;
|
|
263
|
+
current = fs.realpathSync.native(queued);
|
|
264
|
+
const canonicalStat = fs.lstatSync(current, { bigint: true });
|
|
265
|
+
if (!canonicalStat.isDirectory() ||
|
|
266
|
+
canonicalStat.isSymbolicLink() ||
|
|
267
|
+
queuedStat.dev !== canonicalStat.dev ||
|
|
268
|
+
queuedStat.ino === 0n ||
|
|
269
|
+
queuedStat.ino !== canonicalStat.ino) {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const contained = orderedRoots.some((root) => {
|
|
273
|
+
const relative = path.relative(root, current);
|
|
274
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
|
|
275
|
+
});
|
|
276
|
+
if (!contained || visited.has(current))
|
|
277
|
+
continue;
|
|
278
|
+
visited.add(current);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
const dotGit = path.join(current, ".git");
|
|
284
|
+
if (fs.existsSync(dotGit)) {
|
|
285
|
+
let canonical;
|
|
286
|
+
let before;
|
|
287
|
+
try {
|
|
288
|
+
before = fs.lstatSync(current, { bigint: true });
|
|
289
|
+
canonical = fs.realpathSync.native(current);
|
|
290
|
+
if (!before.isDirectory() || before.isSymbolicLink())
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
const origin = readLocalOrigin(canonical);
|
|
297
|
+
let identityStable = false;
|
|
298
|
+
try {
|
|
299
|
+
const after = fs.lstatSync(current, { bigint: true });
|
|
300
|
+
identityStable =
|
|
301
|
+
after.isDirectory() &&
|
|
302
|
+
!after.isSymbolicLink() &&
|
|
303
|
+
before.dev === after.dev &&
|
|
304
|
+
before.ino !== 0n &&
|
|
305
|
+
before.ino === after.ino &&
|
|
306
|
+
fs.realpathSync.native(current) === canonical;
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
identityStable = false;
|
|
310
|
+
}
|
|
311
|
+
repositories.push({
|
|
312
|
+
root: canonical,
|
|
313
|
+
encodedRoot: encodeCheckoutRootSuggestion(canonical),
|
|
314
|
+
...(identityStable ? origin : { reason: "checkout_identity_changed" }),
|
|
315
|
+
});
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
let entries;
|
|
319
|
+
try {
|
|
320
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
entries.sort((left, right) => binaryCompare(left.name, right.name));
|
|
326
|
+
for (const entry of entries) {
|
|
327
|
+
visitedEntries += 1;
|
|
328
|
+
if (visitedEntries > maxEntries)
|
|
329
|
+
refuse("discovery_traversal_bound_exhausted");
|
|
330
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || SKIPPED_DIRECTORIES.has(entry.name)) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
pending.push(path.join(current, entry.name));
|
|
334
|
+
}
|
|
335
|
+
pending.sort(binaryCompare).reverse();
|
|
336
|
+
}
|
|
337
|
+
return repositories.sort((left, right) => binaryCompare(left.root, right.root));
|
|
338
|
+
}
|
|
339
|
+
export function parseDiscoverApprovalArgs(args) {
|
|
340
|
+
const single = new Set([
|
|
341
|
+
"--source-db",
|
|
342
|
+
"--scratch-db",
|
|
343
|
+
"--scratch-state",
|
|
344
|
+
"--out",
|
|
345
|
+
"--max-entries",
|
|
346
|
+
]);
|
|
347
|
+
const values = new Map();
|
|
348
|
+
const searchRoots = [];
|
|
349
|
+
let acknowledged = false;
|
|
350
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
351
|
+
const flag = args[index];
|
|
352
|
+
if (flag === "--acknowledge-private-parent") {
|
|
353
|
+
if (acknowledged)
|
|
354
|
+
refuse("duplicate_cli_argument");
|
|
355
|
+
acknowledged = true;
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const value = args[index + 1];
|
|
359
|
+
if (value === undefined || value.length === 0 || value.startsWith("--")) {
|
|
360
|
+
refuse("missing_cli_value");
|
|
361
|
+
}
|
|
362
|
+
if (flag === "--search-root")
|
|
363
|
+
searchRoots.push(value);
|
|
364
|
+
else {
|
|
365
|
+
if (flag === undefined || !single.has(flag))
|
|
366
|
+
refuse("unknown_cli_argument");
|
|
367
|
+
if (values.has(flag))
|
|
368
|
+
refuse("duplicate_cli_argument");
|
|
369
|
+
values.set(flag, value);
|
|
370
|
+
}
|
|
371
|
+
index += 1;
|
|
372
|
+
}
|
|
373
|
+
const sourceDbPath = values.get("--source-db");
|
|
374
|
+
const scratchDbPath = values.get("--scratch-db");
|
|
375
|
+
const scratchStatePath = values.get("--scratch-state");
|
|
376
|
+
const outputPath = values.get("--out");
|
|
377
|
+
if (sourceDbPath === undefined ||
|
|
378
|
+
scratchDbPath === undefined ||
|
|
379
|
+
scratchStatePath === undefined ||
|
|
380
|
+
outputPath === undefined ||
|
|
381
|
+
searchRoots.length === 0) {
|
|
382
|
+
refuse("missing_required_cli_argument");
|
|
383
|
+
}
|
|
384
|
+
if (!acknowledged)
|
|
385
|
+
refuse("private_artifact_parent_not_acknowledged");
|
|
386
|
+
const maxEntriesText = values.get("--max-entries") ?? String(DEFAULT_MAX_ENTRIES);
|
|
387
|
+
if (!/^\d+$/u.test(maxEntriesText))
|
|
388
|
+
refuse("discovery_max_entries_invalid");
|
|
389
|
+
return {
|
|
390
|
+
sourceDbPath,
|
|
391
|
+
scratchDbPath,
|
|
392
|
+
scratchStatePath,
|
|
393
|
+
searchRoots,
|
|
394
|
+
outputPath,
|
|
395
|
+
maxEntries: Number(maxEntriesText),
|
|
396
|
+
privateArtifactParentAcknowledged: true,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
export async function discoverScratchApprovalCandidate(input, options = {}) {
|
|
400
|
+
const sourceIdentityBefore = readFileIdentity(input.sourceDbPath);
|
|
401
|
+
const workspaces = readWorkspaces(input.sourceDbPath);
|
|
402
|
+
const repositories = enumerateLocalRepositories(input.searchRoots, input.maxEntries, options.testHooks?.beforeVisitDirectory);
|
|
403
|
+
const storedMatches = new Map();
|
|
404
|
+
for (const workspace of workspaces) {
|
|
405
|
+
for (const value of new Set([workspace.workspaceId, workspace.projectSlug])) {
|
|
406
|
+
const matches = storedMatches.get(value) ?? new Set();
|
|
407
|
+
matches.add(workspace.workspaceId);
|
|
408
|
+
storedMatches.set(value, matches);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const reasonCounts = {};
|
|
412
|
+
const proposed = [];
|
|
413
|
+
let existingMappingN = 0;
|
|
414
|
+
let suggestedMappingN = 0;
|
|
415
|
+
let unresolvedWorkspaceN = 0;
|
|
416
|
+
for (const workspace of workspaces) {
|
|
417
|
+
if (workspace.owner !== null && workspace.repo !== null) {
|
|
418
|
+
existingMappingN += 1;
|
|
419
|
+
proposed.push({
|
|
420
|
+
workspaceId: workspace.workspaceId,
|
|
421
|
+
owner: workspace.owner,
|
|
422
|
+
repo: workspace.repo,
|
|
423
|
+
reportAlias: "",
|
|
424
|
+
});
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
const candidates = repositories.filter((repository) => repository.encodedRoot === workspace.workspaceId ||
|
|
428
|
+
repository.encodedRoot === workspace.projectSlug);
|
|
429
|
+
const storedCollision = candidates.some((candidate) => (storedMatches.get(candidate.encodedRoot)?.size ?? 0) > 1);
|
|
430
|
+
if (candidates.length !== 1 || storedCollision) {
|
|
431
|
+
unresolvedWorkspaceN += 1;
|
|
432
|
+
increment(reasonCounts, candidates.length === 0 ? "no_exact_path_match" : "path_encoding_collision");
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
const candidate = candidates[0];
|
|
436
|
+
if (candidate.identity === undefined) {
|
|
437
|
+
unresolvedWorkspaceN += 1;
|
|
438
|
+
increment(reasonCounts, candidate.reason ?? "origin_unresolved");
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
suggestedMappingN += 1;
|
|
442
|
+
proposed.push({ workspaceId: workspace.workspaceId, ...candidate.identity, reportAlias: "" });
|
|
443
|
+
}
|
|
444
|
+
proposed.sort((left, right) => binaryCompare(left.workspaceId, right.workspaceId));
|
|
445
|
+
proposed.forEach((entry, index) => {
|
|
446
|
+
entry.reportAlias = `repo-${String(index + 1).padStart(3, "0")}`;
|
|
447
|
+
});
|
|
448
|
+
if (proposed.length === 0)
|
|
449
|
+
refuse("discovery_no_repository_mappings");
|
|
450
|
+
await options.testHooks?.beforeCandidatePublication?.();
|
|
451
|
+
if (!sourceMetadataUnchanged(input.sourceDbPath, sourceIdentityBefore, workspaces)) {
|
|
452
|
+
refuse("discovery_source_changed");
|
|
453
|
+
}
|
|
454
|
+
const candidate = await createScratchApprovalCandidate({
|
|
455
|
+
sourceDbPath: input.sourceDbPath,
|
|
456
|
+
scratchDbPath: input.scratchDbPath,
|
|
457
|
+
scratchStatePath: input.scratchStatePath,
|
|
458
|
+
repositoriesJson: JSON.stringify(proposed),
|
|
459
|
+
outputPath: input.outputPath,
|
|
460
|
+
privateArtifactParentAcknowledged: true,
|
|
461
|
+
}, { repositoryRoot: options.repositoryRoot ?? REPOSITORY_ROOT });
|
|
462
|
+
await options.testHooks?.afterCandidatePublication?.();
|
|
463
|
+
if (!sourceMetadataUnchanged(input.sourceDbPath, sourceIdentityBefore, workspaces)) {
|
|
464
|
+
refuse("discovery_source_changed_candidate_retained");
|
|
465
|
+
}
|
|
466
|
+
return {
|
|
467
|
+
suggestionHeuristicVersion: PATH_SLUG_SUGGESTION_VERSION,
|
|
468
|
+
sha256: candidate.sha256,
|
|
469
|
+
mappedWorkspaceN: candidate.mappedWorkspaceN,
|
|
470
|
+
existingMappingN,
|
|
471
|
+
suggestedMappingN,
|
|
472
|
+
unresolvedWorkspaceN,
|
|
473
|
+
reasonCounts,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
async function main() {
|
|
477
|
+
try {
|
|
478
|
+
const result = await discoverScratchApprovalCandidate(parseDiscoverApprovalArgs(process.argv.slice(2)));
|
|
479
|
+
process.stdout.write(`${canonicalJson({ status: "SUGGESTIONS_CONFIRMATION_REQUIRED", ...result })}\n`);
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
const failure = error instanceof EvidenceBoundaryError ? error.code : "unexpected_failure";
|
|
483
|
+
process.stderr.write(`${canonicalJson({ status: "REFUSED", failure })}\n`);
|
|
484
|
+
process.exitCode = 1;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const invokedPath = process.argv[1];
|
|
488
|
+
if (invokedPath !== undefined &&
|
|
489
|
+
pathToFileURL(path.resolve(invokedPath)).href ===
|
|
490
|
+
pathToFileURL(fileURLToPath(import.meta.url)).href) {
|
|
491
|
+
void main();
|
|
492
|
+
}
|