@witchpot/gmc 0.6.0 → 0.9.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/CHANGELOG.md +88 -0
- package/README.md +3 -0
- package/package.json +2 -3
- package/skill/gmc-analysis/CHANGELOG.md +197 -0
- package/skill/gmc-analysis/SKILL.md +411 -7
- package/skill/gmc-analysis.manifest.json +2 -2
- package/src/args.js +42 -4
- package/src/constants.js +23 -3
- package/src/help.js +238 -1
- package/src/index.js +1 -0
- package/src/main.js +7 -1
- package/src/main.test.js +130 -0
- package/src/projection.js +21 -0
- package/src/requests.js +474 -2
- package/src/requests.test.js +1065 -0
- package/src/workflows.js +15 -1
- package/src/workflows.test.js +156 -0
- package/scripts/check-release-readiness.mjs +0 -177
- package/scripts/gen-skill-manifest.mjs +0 -151
- package/scripts/smoke-beta.mjs +0 -246
- package/scripts/smoke-pack.mjs +0 -123
package/src/help.js
CHANGED
|
@@ -29,8 +29,11 @@ Usage:
|
|
|
29
29
|
gmc tags --q <term> --limit <number> --json
|
|
30
30
|
gmc lists --json
|
|
31
31
|
gmc lists create --name <name> --kind manual --json
|
|
32
|
-
gmc lists
|
|
32
|
+
gmc lists create --name <name> --kind filter --filter '<json>' --json
|
|
33
|
+
gmc lists games <id> --limit <number> --cursor <cursor> --json
|
|
33
34
|
gmc lists add <id> --app-ids <appid[,appid...]> --json
|
|
35
|
+
gmc lists resolve <id> --json
|
|
36
|
+
gmc lists materialize <id> --confirm --json
|
|
34
37
|
gmc games search --source steam --query <text> --json
|
|
35
38
|
gmc games count --source steam --release-year <year> --reviews-gt <number> --json
|
|
36
39
|
gmc games aggregate --source steam --metric count,median_reviews --group-by release_year --json
|
|
@@ -44,6 +47,11 @@ Usage:
|
|
|
44
47
|
gmc campaigns signals --source steam <externalId> --json
|
|
45
48
|
gmc games compare --source steam <externalId> <externalId...> --focus market --json
|
|
46
49
|
gmc games topics --source steam <externalId> --json
|
|
50
|
+
gmc games language-topics --source steam <externalId> --json
|
|
51
|
+
gmc games language-topics --source steam <externalId> --min-reviews <number> --include-insufficient --json
|
|
52
|
+
gmc games review-history --source steam <externalId> --json
|
|
53
|
+
gmc games more-like-this --source steam <externalId> --country <country> --json
|
|
54
|
+
gmc games ai-disclosure <appid> --json
|
|
47
55
|
gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
|
|
48
56
|
gmc reviews patterns --source steam --tags <tag_slug[,tag_slug...]> --sample-size <number> --preset summary --json
|
|
49
57
|
gmc marketing patterns --source steam --tags <tag_slug[,tag_slug...]> --reviews-min <number> --json
|
|
@@ -56,7 +64,12 @@ Usage:
|
|
|
56
64
|
gmc cohort brief --source steam --list <id> --json
|
|
57
65
|
gmc showcases search --query <text> --json
|
|
58
66
|
gmc showcases fit --source steam <externalId> --deadline-before <YYYY-MM-DD> --json
|
|
67
|
+
gmc showcases history --source steam <externalId> --json
|
|
59
68
|
gmc showcases patterns --source steam --tags <tag_slug[,tag_slug...]> --json
|
|
69
|
+
gmc steam trend-stories --locale en --json
|
|
70
|
+
gmc steam tag-trends --view featured --json
|
|
71
|
+
gmc steam tag-trends --view table --sort release_share_delta --order desc --json
|
|
72
|
+
gmc steam tag-trends --view tag --tag <tag_slug> --json
|
|
60
73
|
|
|
61
74
|
Tag taxonomy:
|
|
62
75
|
gmc tags --json List the full tag dictionary (slug, name).
|
|
@@ -71,11 +84,193 @@ Tag taxonomy:
|
|
|
71
84
|
<term> first to resolve a genre or keyword to valid slugs before
|
|
72
85
|
building a --tags filter.
|
|
73
86
|
|
|
87
|
+
Review topics by review language:
|
|
88
|
+
gmc games language-topics --source steam <externalId> --json
|
|
89
|
+
One game's review topics broken down by
|
|
90
|
+
the language each review was written in.
|
|
91
|
+
--min-reviews <number> Evidence floor applied to the distinct
|
|
92
|
+
review count behind a row, 10-200
|
|
93
|
+
(server default 30).
|
|
94
|
+
--include-insufficient Also return rows below that floor, capped
|
|
95
|
+
by the server, with coverage reporting
|
|
96
|
+
the cut.
|
|
97
|
+
|
|
98
|
+
Read data.evidenceState first. ready means at least one topic x language row
|
|
99
|
+
cleared the floor; insufficient_evidence means rows exist but none cleared
|
|
100
|
+
it; not_analyzed means no rows exist. The last two are thin evidence and no
|
|
101
|
+
data — never report either as zero mentions or absent sentiment.
|
|
102
|
+
|
|
103
|
+
Coverage is the analyzed subset only (about 2,500 titles today), selected
|
|
104
|
+
for review analysis rather than sampled from the catalog. It is not a
|
|
105
|
+
full-catalog population measurement, so do not extrapolate a share of Steam
|
|
106
|
+
from it.
|
|
107
|
+
|
|
108
|
+
Review language is the language a review was written in. It is not a
|
|
109
|
+
country, a region, a market, or a culture, and must not be reported as one.
|
|
110
|
+
|
|
111
|
+
data.rows[].counts and .rates are observed; data.rows[].estimates are
|
|
112
|
+
sample-expanded and carry samplingWeightMethod. Keep the two apart, and date
|
|
113
|
+
every claim with data.snapshot (periodStart/periodEnd/analysisVersion/
|
|
114
|
+
generatedAt).
|
|
115
|
+
|
|
116
|
+
Examples:
|
|
117
|
+
gmc games language-topics --source steam 730 --json
|
|
118
|
+
gmc games language-topics --source steam 730 --min-reviews 50 --json
|
|
119
|
+
gmc games language-topics --source steam 730 --include-insufficient --json
|
|
120
|
+
|
|
121
|
+
Review-count history:
|
|
122
|
+
gmc games review-history --source steam <externalId> --json
|
|
123
|
+
One game's full daily total-review
|
|
124
|
+
series, plus the collected lifecycle and
|
|
125
|
+
announcement events and the observed
|
|
126
|
+
major-discount windows beside it.
|
|
127
|
+
|
|
128
|
+
There are no parameters beyond the external id. The whole series is always
|
|
129
|
+
returned; window it yourself and state the window you chose. A --range,
|
|
130
|
+
--from, --to, --limit or --locale is rejected as a usage error rather than
|
|
131
|
+
ignored, so a narrowing you asked for can never be silently dropped.
|
|
132
|
+
|
|
133
|
+
data.series is published-run-only and contamination-filtered: an observation
|
|
134
|
+
more than 2% below the highest earlier one for the same game is withheld as
|
|
135
|
+
invalid. Differences taken from it are safe to report, but the newest point
|
|
136
|
+
can lag the game's headline review count until that day's run is published.
|
|
137
|
+
Collection can have real gaps, so scale by date and never by array index.
|
|
138
|
+
|
|
139
|
+
Empty data.events or data.discountWindows mean no events were COLLECTED for
|
|
140
|
+
those sources — never that no releases, announcements, or discounts
|
|
141
|
+
happened. Announcements come from official Steam feeds, capped at the most
|
|
142
|
+
recent 1000 items, and are clustered to one event per UTC day.
|
|
143
|
+
|
|
144
|
+
A discount window is an observed state at the >=20% collection threshold,
|
|
145
|
+
not an exact Steam sale period. endDate is EXCLUSIVE (the first observation
|
|
146
|
+
back below the threshold), a null endDate means no end observed yet, and
|
|
147
|
+
discountPercent is the opening observation.
|
|
148
|
+
|
|
149
|
+
Events support temporal comparison only. A review-count movement beside an
|
|
150
|
+
event is co-occurrence, never attribution.
|
|
151
|
+
|
|
152
|
+
Examples:
|
|
153
|
+
gmc games review-history --source steam 730 --json
|
|
154
|
+
|
|
155
|
+
AI-disclosure text and history:
|
|
156
|
+
gmc games ai-disclosure <appid> --json
|
|
157
|
+
One game's developer-authored AI-use
|
|
158
|
+
disclosure text, the store page it was
|
|
159
|
+
read from, and its observed history of
|
|
160
|
+
change. There are no parameters beyond
|
|
161
|
+
the appid.
|
|
162
|
+
|
|
163
|
+
data.state is disclosed, absent, or unconfirmed. absent means no
|
|
164
|
+
disclosure block was observed on the store page -- never that the title
|
|
165
|
+
uses no generative AI. unconfirmed means Game Market Copilot has not yet
|
|
166
|
+
successfully observed the store page either way, including a title never
|
|
167
|
+
attempted at all.
|
|
168
|
+
|
|
169
|
+
data.disclosureText is the developer's own wording, normalized only for
|
|
170
|
+
whitespace and never classified or summarized by Game Market Copilot. It
|
|
171
|
+
is null unless state is disclosed. data.sourceUrl is the store page the
|
|
172
|
+
observation was read from.
|
|
173
|
+
|
|
174
|
+
data.events lists only OBSERVED changes to the state, oldest first; a
|
|
175
|
+
title's first observation is a baseline and never appears as an event.
|
|
176
|
+
|
|
177
|
+
Example:
|
|
178
|
+
gmc games ai-disclosure 730 --json
|
|
179
|
+
|
|
180
|
+
Steam tag trends:
|
|
181
|
+
gmc steam tag-trends --view <view> --json
|
|
182
|
+
Tag supply trends over one frozen monthly
|
|
183
|
+
generation: how the share of new paid
|
|
184
|
+
Steam releases carrying each tag moved
|
|
185
|
+
between two three-month periods.
|
|
186
|
+
--view <view> featured | board | group | table | tag.
|
|
187
|
+
Defaults to featured.
|
|
188
|
+
--family <family> Taxonomy family filter (board/group/table).
|
|
189
|
+
--q <term> Substring over tag slug and name
|
|
190
|
+
(board/group/table).
|
|
191
|
+
--supply-direction <direction> up | down | unresolved | insufficient
|
|
192
|
+
(board/group/table).
|
|
193
|
+
--sort <key> release_share_delta | current_share |
|
|
194
|
+
known_members | median_reviews | slug
|
|
195
|
+
(table only).
|
|
196
|
+
--order <asc|desc> Table sort direction (table only).
|
|
197
|
+
--limit <number> group 1-50, table 1-100.
|
|
198
|
+
--cursor <cursor> Continuation token (group/table).
|
|
199
|
+
--tag <tag_slug> Required for --view tag.
|
|
200
|
+
|
|
201
|
+
Parameters are allowlisted per view and fail closed. Passing one the view
|
|
202
|
+
does not accept is a 400 naming it, not a filter that is quietly ignored, so
|
|
203
|
+
a narrowing you asked for can never be dropped while the answer looks right.
|
|
204
|
+
|
|
205
|
+
Read data.status and data.readiness before quoting anything. status "stale"
|
|
206
|
+
means no newer generation published: the payload keeps its ORIGINAL asOf and
|
|
207
|
+
the values that were truthful then, so date every claim by data.asOf rather
|
|
208
|
+
than by when you ran the command. readiness "limited" is a successful
|
|
209
|
+
partial-data answer, not an error.
|
|
210
|
+
|
|
211
|
+
Report release share as the [lower, upper] interval. The width is UNMEASURED
|
|
212
|
+
titles, not noise: unknownTagMembers are titles with no complete tag
|
|
213
|
+
observation and are never titles known to lack the tag. A
|
|
214
|
+
releaseShareDifferenceRange containing 0 means the data cannot separate the
|
|
215
|
+
two periods.
|
|
216
|
+
|
|
217
|
+
All four directions are measurements. "unresolved" means the difference
|
|
218
|
+
interval spans zero and "insufficient" means the tag is below the evidence
|
|
219
|
+
floor — neither is missing data and neither is "no change".
|
|
220
|
+
|
|
221
|
+
Every observed* field is computed over current.observedReviewMembers titles,
|
|
222
|
+
not over the whole cohort; quote that denominator. item.titles is a bounded
|
|
223
|
+
display sample, not the tag's membership (use current.knownMembers).
|
|
224
|
+
|
|
225
|
+
Before the first generation publishes, the endpoint answers 404
|
|
226
|
+
TAG_TRENDS_UNAVAILABLE. That is the documented pre-publication state, not an
|
|
227
|
+
outage and not worth retrying on a schedule.
|
|
228
|
+
|
|
229
|
+
Examples:
|
|
230
|
+
gmc steam tag-trends --view featured --json
|
|
231
|
+
gmc steam tag-trends --view board --family mechanic --q cozy --json
|
|
232
|
+
gmc steam tag-trends --view table --sort current_share --limit 50 --json
|
|
233
|
+
gmc steam tag-trends --view tag --tag cozy --json
|
|
234
|
+
|
|
74
235
|
Binary aliases:
|
|
75
236
|
gmcp and game-market-copilot run the same CLI. Use them when a shell
|
|
76
237
|
alias shadows gmc (Oh My Zsh's git plugin defines gmc as
|
|
77
238
|
'git merge --continue').
|
|
78
239
|
|
|
240
|
+
Query-backed lists (--kind filter):
|
|
241
|
+
|
|
242
|
+
--filter takes the games API's camelCase filter object, e.g.
|
|
243
|
+
--filter '{"tags":["cozy"],"reviewsMin":500}'
|
|
244
|
+
|
|
245
|
+
It also accepts the version 2 query envelope, which pairs structured
|
|
246
|
+
conditions with an optional natural-language one:
|
|
247
|
+
|
|
248
|
+
{"version":2,"mode":"query",
|
|
249
|
+
"structured":{"tags":["cozy"],"reviewsMin":500},
|
|
250
|
+
"semanticQuery":"games about running a shop",
|
|
251
|
+
"semanticMode":"rank"}
|
|
252
|
+
|
|
253
|
+
structured decides membership. semanticQuery only orders results inside
|
|
254
|
+
that universe under semanticMode "rank" (the default); under "narrow" it
|
|
255
|
+
also narrows membership, and the reported total is then approximate or a
|
|
256
|
+
floor rather than an exact count. Read total_kind in the response before
|
|
257
|
+
quoting any number as exact.
|
|
258
|
+
|
|
259
|
+
A list with a semanticQuery needs a resolution before its games can be
|
|
260
|
+
read: "gmc lists games <id>" answers 409 RESOLUTION_REQUIRED until you run
|
|
261
|
+
"gmc lists resolve <id>". Resolve costs 2 credits every time, including
|
|
262
|
+
when an existing resolution is reused, and it never changes membership.
|
|
263
|
+
|
|
264
|
+
"gmc lists materialize <id> --confirm" FREEZES the list's current members.
|
|
265
|
+
After it the list no longer follows its definition, and the only way back
|
|
266
|
+
is to discard the frozen rows. It costs 2 credits, requires --confirm, and
|
|
267
|
+
takes an optional --expected-revision <n> compare-and-swap so a definition
|
|
268
|
+
edit landing after your resolve fails the call instead of freezing the
|
|
269
|
+
wrong query. A definition using catalogAsOf cannot be frozen at all --
|
|
270
|
+
400 SNAPSHOT_DEFINITION_UNSUPPORTED, nothing written, and retrying will
|
|
271
|
+
never succeed: a snapshot is a fixed set of games, while that predicate is
|
|
272
|
+
re-evaluated against a growing availability history. Read the list live.
|
|
273
|
+
|
|
79
274
|
Supported commands:
|
|
80
275
|
${SUPPORTED_BETA_COMMANDS.map((command) => ` ${command}`).join("\n")}
|
|
81
276
|
|
|
@@ -113,6 +308,39 @@ Filters:
|
|
|
113
308
|
--coverage <list> Research coverage levels: basic, partial, full.
|
|
114
309
|
Use --coverage full,partial to keep only games
|
|
115
310
|
with collected analysis data.
|
|
311
|
+
--has-demo <true|false> Filter on whether the app has a linked demo in
|
|
312
|
+
app_demo_map. false means no demo linkage was
|
|
313
|
+
observed in collected appdetails, not confirmed
|
|
314
|
+
absence.
|
|
315
|
+
--event-name <slug> Restrict to the current participants of one
|
|
316
|
+
Steam event (a steam_events.event_name slug).
|
|
317
|
+
Membership is resolved at query time, so the
|
|
318
|
+
cohort follows collection updates. Unknown slugs
|
|
319
|
+
return INVALID_EVENT with suggestions; list
|
|
320
|
+
valid slugs at GET /api/v1/events.
|
|
321
|
+
--catalog-as-of <date> YYYY-MM-DD. Keep only titles Game Market Copilot
|
|
322
|
+
had NOT observed as delisted on that date. Not a
|
|
323
|
+
snapshot dial: --as-of picks which metrics are
|
|
324
|
+
read, this picks which titles exist, and the two
|
|
325
|
+
combine. Incomplete on purpose -- a title with no
|
|
326
|
+
availability observation by that date is KEPT, so
|
|
327
|
+
a real-data result carries the warning
|
|
328
|
+
catalog_as_of_observation_bounded (plus
|
|
329
|
+
catalog_as_of_predates_coverage when the date is
|
|
330
|
+
older than the observation window) and is not a
|
|
331
|
+
complete historical catalog. A static-fixture
|
|
332
|
+
result carries catalog_as_of_filter_unavailable
|
|
333
|
+
instead: there is no availability history behind
|
|
334
|
+
fixtures, so the filter was not applied at all.
|
|
335
|
+
--release-status <value> released, upcoming, or tba. An exact match on
|
|
336
|
+
top of the guard that already drops any title
|
|
337
|
+
still marked upcoming after its provisional
|
|
338
|
+
release date has passed, on every query.
|
|
339
|
+
--ai-disclosure <value> disclosed, absent, or unconfirmed. absent means
|
|
340
|
+
no AI-use disclosure was observed in collected
|
|
341
|
+
data -- not confirmed non-use. unconfirmed means
|
|
342
|
+
Game Market Copilot has not yet successfully
|
|
343
|
+
observed a disclosure state either way.
|
|
116
344
|
|
|
117
345
|
Sorting:
|
|
118
346
|
--sort <key> reviews_desc, reviews_asc, rating_desc,
|
|
@@ -134,11 +362,20 @@ Count and aggregation:
|
|
|
134
362
|
Use gmc games count when you only need the number of matching games.
|
|
135
363
|
Use gmc games aggregate for grouped metrics such as count by year,
|
|
136
364
|
median reviews by year, or count by an explicit price/review/rating bucket.
|
|
365
|
+
Add --as-of to read a past published snapshot, and --compare-as-of to get a
|
|
366
|
+
per-app-paired comparison between two dates in data.comparison. Report the
|
|
367
|
+
comparison's exclusions, paired_beyond_lookback and insufficient_coverage
|
|
368
|
+
rather than inferring a trend from them. --compare-as-of does not support
|
|
369
|
+
--group-by tag, primary_market_tag, or ai_disclosure, nor the
|
|
370
|
+
--ai-disclosure filter. It DOES combine with --event-name and
|
|
371
|
+
--catalog-as-of: the comparison cohort applies the same Steam event
|
|
372
|
+
participation and catalog-availability predicates as the rows beside it.
|
|
137
373
|
|
|
138
374
|
Examples:
|
|
139
375
|
gmc games search --source steam --release-year 2025 --reviews-min 1000 --json
|
|
140
376
|
gmc games count --source steam --release-year 2025 --reviews-gt 1000 --json
|
|
141
377
|
gmc games aggregate --source steam --metric count,median_reviews --group-by release_year --reviews-gt 1000 --json
|
|
142
378
|
gmc games aggregate --source steam --metric count --group-by price_bucket --bucket price:0,5,10,20,40 --json
|
|
379
|
+
gmc games aggregate --source steam --metric count --group-by release_year --as-of 2026-08-01 --compare-as-of 2026-06-01 --json
|
|
143
380
|
`);
|
|
144
381
|
}
|
package/src/index.js
CHANGED
package/src/main.js
CHANGED
|
@@ -10,7 +10,7 @@ import { matches, parseArgs } from "./args.js";
|
|
|
10
10
|
import { handleLocalCommand, loadConfig, parseBaseUrl } from "./config.js";
|
|
11
11
|
import { printGamesSearchHelp, printHelp } from "./help.js";
|
|
12
12
|
import { writeJsonError } from "./output.js";
|
|
13
|
-
import { applyOutputProjection } from "./projection.js";
|
|
13
|
+
import { applyOutputProjection, assertPresetApplicable } from "./projection.js";
|
|
14
14
|
import { buildRequest } from "./requests.js";
|
|
15
15
|
import { trackCliCommandExecuted } from "./telemetry.js";
|
|
16
16
|
import { executeRequestPlan } from "./workflows.js";
|
|
@@ -71,6 +71,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
71
71
|
process.exit(EXIT_USAGE);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// NAK-804 cross-model review: validated BEFORE the metered request below,
|
|
75
|
+
// not only afterward inside applyOutputProjection — an inapplicable
|
|
76
|
+
// --preset must never pay for the HTTP round trip (or its credit charge)
|
|
77
|
+
// only to be rejected once the response is already back.
|
|
78
|
+
assertPresetApplicable({ fields: options.fields, preset: options.preset, request });
|
|
79
|
+
|
|
74
80
|
if (request.requiresApiKey && !apiKey) {
|
|
75
81
|
writeJsonError({
|
|
76
82
|
code: "CLI_MISSING_CONFIG",
|
package/src/main.test.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
|
|
4
|
+
import { main } from "./main.js";
|
|
5
|
+
import { assertPresetApplicable } from "./projection.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Async counterpart to the `captureUsageFailure` helper in requests.test.js:
|
|
9
|
+
* `main()` is async, so `process.exit` must be mocked to throw INTO the
|
|
10
|
+
* awaited call rather than a synchronous one.
|
|
11
|
+
*/
|
|
12
|
+
async function captureCliExit(run) {
|
|
13
|
+
const originalWrite = process.stdout.write;
|
|
14
|
+
const originalExit = process.exit;
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let exitCode;
|
|
17
|
+
process.stdout.write = (chunk) => {
|
|
18
|
+
chunks.push(String(chunk));
|
|
19
|
+
return true;
|
|
20
|
+
};
|
|
21
|
+
process.exit = (code) => {
|
|
22
|
+
exitCode = code;
|
|
23
|
+
throw new Error("__cli_exit__");
|
|
24
|
+
};
|
|
25
|
+
try {
|
|
26
|
+
await run();
|
|
27
|
+
throw new Error("expected the CLI to exit");
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.message !== "__cli_exit__") throw error;
|
|
30
|
+
} finally {
|
|
31
|
+
process.stdout.write = originalWrite;
|
|
32
|
+
process.exit = originalExit;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { output: JSON.parse(chunks.join("")), exitCode };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* NAK-804 cross-model review (Codex Sol): `gmc games ai-disclosure 730
|
|
40
|
+
* --preset summary --json` used to build the request, send it, and only
|
|
41
|
+
* THEN discover in applyOutputProjection that ai-disclosure supports no
|
|
42
|
+
* preset — charging a credit for a call whose answer was always going to be
|
|
43
|
+
* a usage error. The counting fetch stub below is the direct evidence that
|
|
44
|
+
* the metered request never fires: if the ordering regresses, this test
|
|
45
|
+
* fails on `fetchCalls === 0` even though the exit code/message stay right.
|
|
46
|
+
*/
|
|
47
|
+
test("(NAK-804) an unsupported --preset is rejected before the metered request executes", async () => {
|
|
48
|
+
const originalFetch = globalThis.fetch;
|
|
49
|
+
let fetchCalls = 0;
|
|
50
|
+
globalThis.fetch = async () => {
|
|
51
|
+
fetchCalls += 1;
|
|
52
|
+
throw new Error("fetch must not be called once --preset is rejected");
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const { output, exitCode } = await captureCliExit(() =>
|
|
57
|
+
main(["games", "ai-disclosure", "730", "--preset", "summary", "--json"])
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
assert.equal(fetchCalls, 0);
|
|
61
|
+
assert.equal(exitCode, 2);
|
|
62
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
63
|
+
assert.match(
|
|
64
|
+
output.error.message,
|
|
65
|
+
/--preset summary is supported for gmc reviews patterns, gmc games benchmark, and gmc cohort\/pattern workflows/
|
|
66
|
+
);
|
|
67
|
+
} finally {
|
|
68
|
+
globalThis.fetch = originalFetch;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The same early check is generic (it reuses resolvePresetFields exactly),
|
|
74
|
+
* not an ai-disclosure special case: an unknown preset NAME is rejected
|
|
75
|
+
* before the request too, for any command.
|
|
76
|
+
*/
|
|
77
|
+
test("(NAK-804) an unknown --preset value is rejected before the metered request executes", async () => {
|
|
78
|
+
const originalFetch = globalThis.fetch;
|
|
79
|
+
let fetchCalls = 0;
|
|
80
|
+
globalThis.fetch = async () => {
|
|
81
|
+
fetchCalls += 1;
|
|
82
|
+
throw new Error("fetch must not be called once --preset is rejected");
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const { output, exitCode } = await captureCliExit(() =>
|
|
87
|
+
main(["games", "ai-disclosure", "730", "--preset", "not-a-real-preset", "--json"])
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
assert.equal(fetchCalls, 0);
|
|
91
|
+
assert.equal(exitCode, 2);
|
|
92
|
+
assert.equal(output.error.code, "CLI_USAGE_ERROR");
|
|
93
|
+
assert.match(output.error.message, /--preset must be one of: summary/);
|
|
94
|
+
} finally {
|
|
95
|
+
globalThis.fetch = originalFetch;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Negative control on the pure check itself (no CLI/network plumbing
|
|
101
|
+
* needed): a request `kind` that IS in the preset allowlist must not be
|
|
102
|
+
* rejected — the early check only intercepts INAPPLICABLE presets, not
|
|
103
|
+
* every --preset call.
|
|
104
|
+
*/
|
|
105
|
+
test("(NAK-804) assertPresetApplicable does not reject a preset the request kind supports", () => {
|
|
106
|
+
assert.doesNotThrow(() =>
|
|
107
|
+
assertPresetApplicable({ preset: "summary", request: { kind: "reviewPatterns" } })
|
|
108
|
+
);
|
|
109
|
+
assert.doesNotThrow(() =>
|
|
110
|
+
assertPresetApplicable({ preset: "summary", request: { kind: "benchmark" } })
|
|
111
|
+
);
|
|
112
|
+
assert.doesNotThrow(() =>
|
|
113
|
+
assertPresetApplicable({ preset: "summary", request: { kind: "cohortEvidence" } })
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Same precedence rule applyOutputProjection enforces: --fields silences an
|
|
119
|
+
* otherwise-inapplicable --preset entirely, so passing both together must
|
|
120
|
+
* not trip the early check either.
|
|
121
|
+
*/
|
|
122
|
+
test("(NAK-804) assertPresetApplicable defers to --fields precedence, like applyOutputProjection", () => {
|
|
123
|
+
assert.doesNotThrow(() =>
|
|
124
|
+
assertPresetApplicable({
|
|
125
|
+
fields: "state",
|
|
126
|
+
preset: "summary",
|
|
127
|
+
request: { kind: undefined }
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
});
|
package/src/projection.js
CHANGED
|
@@ -47,6 +47,27 @@ export function applyOutputProjection(payload, { fields, preset, request } = {})
|
|
|
47
47
|
return applyFieldsProjection(payload, presetFields, `preset ${preset}`);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* NAK-804 cross-model review (Codex Sol): every metered command built a
|
|
52
|
+
* usage-error-only exit on an inapplicable --preset, but the check ran
|
|
53
|
+
* INSIDE applyOutputProjection, which main() calls only AFTER the HTTP
|
|
54
|
+
* request already executed (and, for a metered endpoint, already charged a
|
|
55
|
+
* credit). This is the same validation, called right after buildRequest()
|
|
56
|
+
* and before that request is ever sent, so the usage error lands before any
|
|
57
|
+
* network call or charge. General fix, not ai-disclosure-specific: it reuses
|
|
58
|
+
* resolvePresetFields exactly, so any command missing from its
|
|
59
|
+
* kind-allowlist gets the early rejection for free.
|
|
60
|
+
*
|
|
61
|
+
* Mirrors applyOutputProjection's own precedence: --fields silences --preset
|
|
62
|
+
* entirely (never even inspected) both here and there, so the two agree on
|
|
63
|
+
* every input rather than only on the paths that reach a real request.
|
|
64
|
+
*/
|
|
65
|
+
export function assertPresetApplicable({ fields, preset, request } = {}) {
|
|
66
|
+
if (fields != null && fields !== "") return;
|
|
67
|
+
if (preset == null || preset === "") return;
|
|
68
|
+
resolvePresetFields(preset, request);
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
function resolvePresetFields(preset, request) {
|
|
51
72
|
if (preset !== "summary") {
|
|
52
73
|
writeJsonError({
|