@appeeky/spec 0.0.0-stage → 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.
Files changed (86) hide show
  1. package/.github/assets/cover.gif +0 -0
  2. package/LICENSE +110 -0
  3. package/README.md +483 -2
  4. package/dist/ads.d.ts +39 -0
  5. package/dist/ads.js +67 -0
  6. package/dist/appeeky-api.d.ts +62 -0
  7. package/dist/appeeky-api.js +240 -0
  8. package/dist/attribution-setup.d.ts +28 -0
  9. package/dist/attribution-setup.js +65 -0
  10. package/dist/attribution.d.ts +121 -0
  11. package/dist/attribution.js +170 -0
  12. package/dist/bin.d.ts +2 -0
  13. package/dist/bin.js +4 -0
  14. package/dist/category.d.ts +71 -0
  15. package/dist/category.js +198 -0
  16. package/dist/cli.d.ts +3 -0
  17. package/dist/cli.js +1387 -0
  18. package/dist/competitors.d.ts +74 -0
  19. package/dist/competitors.js +143 -0
  20. package/dist/control.d.ts +21 -0
  21. package/dist/control.js +53 -0
  22. package/dist/core.d.ts +15 -0
  23. package/dist/core.js +15 -0
  24. package/dist/coverage.d.ts +46 -0
  25. package/dist/coverage.js +115 -0
  26. package/dist/data.d.ts +48 -0
  27. package/dist/data.js +298 -0
  28. package/dist/diff.d.ts +11 -0
  29. package/dist/diff.js +33 -0
  30. package/dist/experiments.d.ts +115 -0
  31. package/dist/experiments.js +85 -0
  32. package/dist/files.d.ts +27 -0
  33. package/dist/files.js +129 -0
  34. package/dist/history.d.ts +43 -0
  35. package/dist/history.js +55 -0
  36. package/dist/index.d.ts +24 -0
  37. package/dist/index.js +24 -0
  38. package/dist/limits.d.ts +15 -0
  39. package/dist/limits.js +30 -0
  40. package/dist/lint.d.ts +16 -0
  41. package/dist/lint.js +159 -0
  42. package/dist/loops.d.ts +37 -0
  43. package/dist/loops.js +197 -0
  44. package/dist/measure.d.ts +40 -0
  45. package/dist/measure.js +159 -0
  46. package/dist/metrics.d.ts +270 -0
  47. package/dist/metrics.js +250 -0
  48. package/dist/output.d.ts +21 -0
  49. package/dist/output.js +79 -0
  50. package/dist/project.d.ts +19 -0
  51. package/dist/project.js +35 -0
  52. package/dist/report.d.ts +5 -0
  53. package/dist/report.js +71 -0
  54. package/dist/reviews.d.ts +130 -0
  55. package/dist/reviews.js +287 -0
  56. package/dist/schema.d.ts +519 -0
  57. package/dist/schema.js +218 -0
  58. package/dist/screenshots.d.ts +23 -0
  59. package/dist/screenshots.js +116 -0
  60. package/dist/setup.d.ts +45 -0
  61. package/dist/setup.js +226 -0
  62. package/dist/storefronts.d.ts +15 -0
  63. package/dist/storefronts.js +89 -0
  64. package/dist/stores/app-store.d.ts +33 -0
  65. package/dist/stores/app-store.js +159 -0
  66. package/dist/stores/google-play.d.ts +13 -0
  67. package/dist/stores/google-play.js +61 -0
  68. package/dist/stores/types.d.ts +34 -0
  69. package/dist/stores/types.js +2 -0
  70. package/dist/suggest.d.ts +73 -0
  71. package/dist/suggest.js +135 -0
  72. package/dist/text.d.ts +18 -0
  73. package/dist/text.js +55 -0
  74. package/dist/transport.d.ts +98 -0
  75. package/dist/transport.js +170 -0
  76. package/dist/verify.d.ts +70 -0
  77. package/dist/verify.js +134 -0
  78. package/dist/visibility.d.ts +149 -0
  79. package/dist/visibility.js +239 -0
  80. package/package.json +76 -4
  81. package/skills/appeeky-spec/SKILL.md +146 -0
  82. package/skills/appeeky-spec-experiments/SKILL.md +83 -0
  83. package/skills/appeeky-spec-localize/SKILL.md +52 -0
  84. package/skills/appeeky-spec-review/SKILL.md +75 -0
  85. package/skills/appeeky-spec-targets/SKILL.md +66 -0
  86. package/skills/appeeky-spec-visibility/SKILL.md +79 -0
package/dist/files.js ADDED
@@ -0,0 +1,129 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import YAML from "yaml";
5
+ import { appStoreListingSchema, fieldsFor, googlePlayListingSchema, specFileSchema, STORES, } from "./schema.js";
6
+ export const SPEC_FILE = "appeeky.spec.yaml";
7
+ export const SPEC_DIR = "appeeky";
8
+ export class SpecError extends Error {
9
+ file;
10
+ constructor(message, file) {
11
+ super(file ? `${file}: ${message}` : message);
12
+ this.file = file;
13
+ this.name = "SpecError";
14
+ }
15
+ }
16
+ export function listingsDir(root, store) {
17
+ return join(root, SPEC_DIR, "store", store);
18
+ }
19
+ export function listingPath(root, store, locale) {
20
+ return join(listingsDir(root, store), `${locale}.yaml`);
21
+ }
22
+ /** Walk up from `start` to the folder that holds `appeeky.spec.yaml`. */
23
+ export function findRoot(start = process.cwd()) {
24
+ let dir = resolve(start);
25
+ for (;;) {
26
+ if (existsSync(join(dir, SPEC_FILE)))
27
+ return dir;
28
+ const parent = dirname(dir);
29
+ if (parent === dir)
30
+ return undefined;
31
+ dir = parent;
32
+ }
33
+ }
34
+ function parse(schema, raw, file) {
35
+ const result = schema.safeParse(raw ?? {});
36
+ if (!result.success) {
37
+ const detail = result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
38
+ throw new SpecError(detail, file);
39
+ }
40
+ return result.data;
41
+ }
42
+ async function readYaml(file) {
43
+ const text = await readFile(file, "utf8");
44
+ try {
45
+ return YAML.parse(text);
46
+ }
47
+ catch (err) {
48
+ throw new SpecError(err.message, file);
49
+ }
50
+ }
51
+ export function parseSpec(raw, file = SPEC_FILE) {
52
+ return parse(specFileSchema, raw, file);
53
+ }
54
+ export function parseListing(store, raw, file) {
55
+ return store === "app-store" ? parse(appStoreListingSchema, raw, file) : parse(googlePlayListingSchema, raw, file);
56
+ }
57
+ /** Load the spec and every listing file under `appeeky/store/`. */
58
+ export async function loadProject(start) {
59
+ const root = findRoot(start);
60
+ if (!root)
61
+ throw new SpecError(`No ${SPEC_FILE} found here or in a parent folder. Run \`appeeky-spec init\` first.`);
62
+ const spec = parseSpec(await readYaml(join(root, SPEC_FILE)), join(root, SPEC_FILE));
63
+ const project = { root, spec, listings: { "app-store": {}, "google-play": {} } };
64
+ for (const store of STORES) {
65
+ const dir = listingsDir(root, store);
66
+ if (!existsSync(dir))
67
+ continue;
68
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
69
+ for (const f of files) {
70
+ const locale = f.replace(/\.ya?ml$/, "");
71
+ const file = join(dir, f);
72
+ project.listings[store][locale] = parseListing(store, await readYaml(file), file);
73
+ }
74
+ }
75
+ return project;
76
+ }
77
+ /** Keep the store's field order and use block scalars for multi-line text so diffs stay readable. */
78
+ export function stringifyListing(store, listing) {
79
+ const ordered = {};
80
+ for (const field of fieldsFor(store)) {
81
+ const value = listing[field];
82
+ if (value !== undefined)
83
+ ordered[field] = value;
84
+ }
85
+ return YAML.stringify(ordered, { lineWidth: 0, blockQuote: "literal" });
86
+ }
87
+ export async function writeListing(root, store, locale, listing) {
88
+ const file = listingPath(root, store, locale);
89
+ await mkdir(dirname(file), { recursive: true });
90
+ await writeFile(file, stringifyListing(store, listing));
91
+ return file;
92
+ }
93
+ export function stringifySpec(spec) {
94
+ // Optional sections stay out of the file until they have content.
95
+ const clean = { ...spec };
96
+ for (const k of ["storefronts", "personas"]) {
97
+ const v = clean[k];
98
+ if (v === undefined || (Array.isArray(v) ? v.length === 0 : Object.keys(v).length === 0))
99
+ delete clean[k];
100
+ }
101
+ const doc = new YAML.Document(clean);
102
+ doc.commentBefore =
103
+ " Appeeky Spec: your app's store presence as a spec.\n Docs: https://github.com/appeeky/appeeky-spec";
104
+ return doc.toString({ lineWidth: 0 });
105
+ }
106
+ export async function writeSpec(root, spec) {
107
+ const file = join(root, SPEC_FILE);
108
+ await writeFile(file, stringifySpec(spec));
109
+ return file;
110
+ }
111
+ const PULL_STATE = join(".appeeky", "pulled.json");
112
+ export async function readPullState(root) {
113
+ const file = join(root, PULL_STATE);
114
+ if (!existsSync(file))
115
+ return {};
116
+ try {
117
+ return JSON.parse(await readFile(file, "utf8"));
118
+ }
119
+ catch {
120
+ return {};
121
+ }
122
+ }
123
+ export async function recordPull(root, store, target) {
124
+ const state = { ...(await readPullState(root)), [store]: { target, at: new Date().toISOString() } };
125
+ const file = join(root, PULL_STATE);
126
+ await mkdir(dirname(file), { recursive: true });
127
+ await writeFile(file, `${JSON.stringify(state, null, 2)}\n`);
128
+ }
129
+ //# sourceMappingURL=files.js.map
@@ -0,0 +1,43 @@
1
+ import type { StoreId } from "./schema.js";
2
+ import type { FieldChange } from "./stores/types.js";
3
+ import { type MetricsCheck } from "./metrics.js";
4
+ import type { ConversionChange, RankEvaluation, RankSnapshot } from "./verify.js";
5
+ /**
6
+ * Every push writes a release record to `appeeky/releases/`. It is the
7
+ * history of what went live and when, the input for measuring impact later,
8
+ * and what a revert pushes back.
9
+ */
10
+ export interface ReleaseRecord {
11
+ id: string;
12
+ pushedAt: string;
13
+ store: StoreId;
14
+ /** Where it went, e.g. "version 2.4 (prepare for submission)". */
15
+ target: string;
16
+ via: string;
17
+ /** Free-text reason or hypothesis, from `push --note`. */
18
+ note?: string;
19
+ changes: Array<Pick<FieldChange, "locale" | "field" | "before" | "after">>;
20
+ /** Search ranks of the targets right before the push. */
21
+ baseline?: {
22
+ at: string;
23
+ source: string;
24
+ ranks: RankSnapshot;
25
+ };
26
+ /** Rank checks after the push (`appeeky-spec verify`). */
27
+ checks?: Array<{
28
+ at: string;
29
+ day: number;
30
+ ranks: RankSnapshot;
31
+ conversion?: ConversionChange;
32
+ metrics?: MetricsCheck;
33
+ } & RankEvaluation>;
34
+ }
35
+ /** Days since a release went out. */
36
+ export declare function releaseAge(record: ReleaseRecord, now?: Date): number;
37
+ /** Markdown summary of a release: for PR descriptions, changelogs and team updates. */
38
+ export declare function releaseMarkdown(record: ReleaseRecord): string;
39
+ export declare function releasesDir(root: string): string;
40
+ export declare function releaseId(date: Date, store: StoreId): string;
41
+ export declare function writeRelease(root: string, record: ReleaseRecord): Promise<string>;
42
+ export declare function updateRelease(root: string, record: ReleaseRecord): Promise<void>;
43
+ export declare function readReleases(root: string): Promise<ReleaseRecord[]>;
@@ -0,0 +1,55 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import YAML from "yaml";
5
+ import { SPEC_DIR } from "./files.js";
6
+ import { formatChange } from "./metrics.js";
7
+ /** Days since a release went out. */
8
+ export function releaseAge(record, now = new Date()) {
9
+ return Math.floor((now.getTime() - Date.parse(record.pushedAt)) / 86_400_000);
10
+ }
11
+ /** Markdown summary of a release: for PR descriptions, changelogs and team updates. */
12
+ export function releaseMarkdown(record) {
13
+ const lines = [`### ${record.store === "app-store" ? "App Store" : "Google Play"} · ${record.target}`];
14
+ if (record.note)
15
+ lines.push("", `> ${record.note}`);
16
+ lines.push("", "| Locale | Field | Before | After |", "| --- | --- | --- | --- |");
17
+ const cell = (v) => (v ?? "").replace(/\n+/g, " ").replace(/\|/g, "\\|").slice(0, 80) || "—";
18
+ for (const c of record.changes)
19
+ lines.push(`| ${c.locale} | ${c.field} | ${cell(c.before)} | ${cell(c.after)} |`);
20
+ const last = record.checks?.at(-1);
21
+ if (last) {
22
+ const conv = last.conversion ? `, conversion ${(last.conversion.before.rate * 100).toFixed(1)}% → ${(last.conversion.after.rate * 100).toFixed(1)}%` : "";
23
+ lines.push("", `Day ${last.day}: **${last.verdict}** (${last.improved.length} up, ${last.dropped.length} down, ${last.regressions.length} top-20 lost${conv})`);
24
+ for (const m of last.metrics?.changes ?? [])
25
+ lines.push(`- ${formatChange(m, last.metrics?.after.currency)}`);
26
+ }
27
+ return lines.join("\n");
28
+ }
29
+ export function releasesDir(root) {
30
+ return join(root, SPEC_DIR, "releases");
31
+ }
32
+ export function releaseId(date, store) {
33
+ return `${date.toISOString().replace(/[:.]/g, "-").replace(/-\d{3}Z$/, "Z")}-${store}`;
34
+ }
35
+ export async function writeRelease(root, record) {
36
+ const dir = releasesDir(root);
37
+ await mkdir(dir, { recursive: true });
38
+ const file = join(dir, `${record.id}.yaml`);
39
+ await writeFile(file, YAML.stringify(record, { lineWidth: 0, blockQuote: "literal" }));
40
+ return file;
41
+ }
42
+ export async function updateRelease(root, record) {
43
+ await writeFile(join(releasesDir(root), `${record.id}.yaml`), YAML.stringify(record, { lineWidth: 0, blockQuote: "literal" }));
44
+ }
45
+ export async function readReleases(root) {
46
+ const dir = releasesDir(root);
47
+ if (!existsSync(dir))
48
+ return [];
49
+ const files = (await readdir(dir)).filter((f) => f.endsWith(".yaml")).sort().reverse();
50
+ const out = [];
51
+ for (const f of files)
52
+ out.push(YAML.parse(await readFile(join(dir, f), "utf8")));
53
+ return out;
54
+ }
55
+ //# sourceMappingURL=history.js.map
@@ -0,0 +1,24 @@
1
+ export * from "./schema.js";
2
+ export * from "./limits.js";
3
+ export * from "./text.js";
4
+ export * from "./lint.js";
5
+ export * from "./coverage.js";
6
+ export * from "./storefronts.js";
7
+ export * from "./diff.js";
8
+ export * from "./files.js";
9
+ export * from "./history.js";
10
+ export * from "./transport.js";
11
+ export * from "./project.js";
12
+ export * from "./appeeky-api.js";
13
+ export * from "./suggest.js";
14
+ export * from "./verify.js";
15
+ export * from "./measure.js";
16
+ export * from "./metrics.js";
17
+ export * from "./experiments.js";
18
+ export * from "./screenshots.js";
19
+ export * from "./report.js";
20
+ export * from "./category.js";
21
+ export * from "./visibility.js";
22
+ export type { FieldChange, PushResult, RemoteState, StoreAdapter } from "./stores/types.js";
23
+ export { AppStoreAdapter, type AppStoreContext } from "./stores/app-store.js";
24
+ export { GooglePlayAdapter } from "./stores/google-play.js";
package/dist/index.js ADDED
@@ -0,0 +1,24 @@
1
+ export * from "./schema.js";
2
+ export * from "./limits.js";
3
+ export * from "./text.js";
4
+ export * from "./lint.js";
5
+ export * from "./coverage.js";
6
+ export * from "./storefronts.js";
7
+ export * from "./diff.js";
8
+ export * from "./files.js";
9
+ export * from "./history.js";
10
+ export * from "./transport.js";
11
+ export * from "./project.js";
12
+ export * from "./appeeky-api.js";
13
+ export * from "./suggest.js";
14
+ export * from "./verify.js";
15
+ export * from "./measure.js";
16
+ export * from "./metrics.js";
17
+ export * from "./experiments.js";
18
+ export * from "./screenshots.js";
19
+ export * from "./report.js";
20
+ export * from "./category.js";
21
+ export * from "./visibility.js";
22
+ export { AppStoreAdapter } from "./stores/app-store.js";
23
+ export { GooglePlayAdapter } from "./stores/google-play.js";
24
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ import type { StoreId } from "./schema.js";
2
+ export interface FieldLimit {
3
+ max: number;
4
+ /** Characters as App Store Connect counts them (a Cyrillic or CJK letter is one). */
5
+ unit: "chars";
6
+ }
7
+ export declare const LIMITS: Record<StoreId, Record<string, FieldLimit>>;
8
+ /**
9
+ * Fields that go live without a new app version. On the App Store only the
10
+ * promotional text changes on the live version; everything else waits for the
11
+ * next version you submit.
12
+ */
13
+ export declare const LIVE_EDITABLE: Record<StoreId, ReadonlySet<string>>;
14
+ /** Fields the store indexes for search. */
15
+ export declare const INDEXED_FIELDS: Record<StoreId, readonly string[]>;
package/dist/limits.js ADDED
@@ -0,0 +1,30 @@
1
+ export const LIMITS = {
2
+ "app-store": {
3
+ name: { max: 30, unit: "chars" },
4
+ subtitle: { max: 30, unit: "chars" },
5
+ keywords: { max: 100, unit: "chars" },
6
+ promotionalText: { max: 170, unit: "chars" },
7
+ description: { max: 4000, unit: "chars" },
8
+ whatsNew: { max: 4000, unit: "chars" },
9
+ },
10
+ "google-play": {
11
+ title: { max: 30, unit: "chars" },
12
+ shortDescription: { max: 80, unit: "chars" },
13
+ fullDescription: { max: 4000, unit: "chars" },
14
+ },
15
+ };
16
+ /**
17
+ * Fields that go live without a new app version. On the App Store only the
18
+ * promotional text changes on the live version; everything else waits for the
19
+ * next version you submit.
20
+ */
21
+ export const LIVE_EDITABLE = {
22
+ "app-store": new Set(["promotionalText"]),
23
+ "google-play": new Set(["title", "shortDescription", "fullDescription", "video"]),
24
+ };
25
+ /** Fields the store indexes for search. */
26
+ export const INDEXED_FIELDS = {
27
+ "app-store": ["name", "subtitle", "keywords"],
28
+ "google-play": ["title", "shortDescription", "fullDescription"],
29
+ };
30
+ //# sourceMappingURL=limits.js.map
package/dist/lint.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { AppStoreListing, GooglePlayListing, Project, StoreId } from "./schema.js";
2
+ export type Severity = "error" | "warning" | "info";
3
+ export interface Issue {
4
+ severity: Severity;
5
+ /** Stable rule id, e.g. `limit`, `keywords/duplicate-of-name`. */
6
+ rule: string;
7
+ store: StoreId;
8
+ locale: string;
9
+ field?: string;
10
+ message: string;
11
+ }
12
+ export declare function lintLength(store: StoreId, locale: string, field: string, value: string | undefined): Issue[];
13
+ export declare function lintAppStore(locale: string, listing: AppStoreListing): Issue[];
14
+ export declare function lintGooglePlay(locale: string, listing: GooglePlayListing): Issue[];
15
+ export declare function lintProject(project: Project): Issue[];
16
+ export declare function hasErrors(issues: Issue[]): boolean;
package/dist/lint.js ADDED
@@ -0,0 +1,159 @@
1
+ import { LIMITS } from "./limits.js";
2
+ import { charLength, isEnglish, keywordEntries, singular, unique, words } from "./text.js";
3
+ /** Words Apple already ignores or that buy nothing in the keyword field. */
4
+ const APPLE_FILLER = new Set(["app", "apps", "the", "a", "an", "and", "or", "for", "with", "of", "iphone", "ipad"]);
5
+ /** Google Play's metadata policy rejects these in the title. */
6
+ const PLAY_TITLE_BANNED = [/\bbest\b/i, /#\s?1\b/, /\btop\b/i, /\bfree\b/i, /\bno\.?\s?1\b/i, /\bsale\b/i, /\bdiscount\b/i];
7
+ export function lintLength(store, locale, field, value) {
8
+ const limit = LIMITS[store][field];
9
+ if (!limit || value === undefined)
10
+ return [];
11
+ const used = charLength(value);
12
+ if (used <= limit.max)
13
+ return [];
14
+ return [
15
+ {
16
+ severity: "error",
17
+ rule: "limit",
18
+ store,
19
+ locale,
20
+ field,
21
+ message: `${field} is ${used}/${limit.max} characters; the store rejects it.`,
22
+ },
23
+ ];
24
+ }
25
+ export function lintAppStore(locale, listing) {
26
+ const issues = [];
27
+ const at = (severity, rule, field, message) => issues.push({ severity, rule, store: "app-store", locale, field, message });
28
+ for (const [field, value] of Object.entries(listing)) {
29
+ issues.push(...lintLength("app-store", locale, field, value));
30
+ }
31
+ const nameWords = new Set(words(listing.name));
32
+ const subtitleWords = words(listing.subtitle);
33
+ const repeatedInSubtitle = unique(subtitleWords.filter((w) => nameWords.has(w)));
34
+ if (repeatedInSubtitle.length > 0) {
35
+ at("warning", "subtitle/repeats-name", "subtitle", `Subtitle repeats ${quoteList(repeatedInSubtitle)} from the name. Each word is indexed once, so use the space for a new word.`);
36
+ }
37
+ if (listing.keywords !== undefined) {
38
+ const field = listing.keywords;
39
+ if (/,\s+|\s+,/.test(field)) {
40
+ at("warning", "keywords/spaces", "keywords", "Spaces around commas waste characters in the keyword field. Write `a,b,c`.");
41
+ }
42
+ const entries = keywordEntries(field);
43
+ if (entries.length === 1 && words(entries[0]).length >= 4) {
44
+ at("warning", "keywords/no-commas", "keywords", "The keyword field has no commas, so it reads as one long phrase. Separate keywords with commas: `a,b,c`.");
45
+ }
46
+ const seen = new Map();
47
+ for (const entry of entries)
48
+ seen.set(entry.toLowerCase(), (seen.get(entry.toLowerCase()) ?? 0) + 1);
49
+ const repeated = [...seen].filter(([, n]) => n > 1).map(([e]) => e);
50
+ if (repeated.length > 0) {
51
+ at("warning", "keywords/duplicate", "keywords", `The keyword field lists ${quoteList(repeated)} more than once.`);
52
+ }
53
+ const keywordWords = entries.flatMap((e) => words(e));
54
+ const titled = new Set([...nameWords, ...subtitleWords]);
55
+ const alreadyIndexed = unique(keywordWords.filter((w) => titled.has(w)));
56
+ if (alreadyIndexed.length > 0) {
57
+ at("warning", "keywords/duplicate-of-name", "keywords", `${quoteList(alreadyIndexed)} already ${alreadyIndexed.length === 1 ? "appears" : "appear"} in the name or subtitle, so the keyword field does not need ${alreadyIndexed.length === 1 ? "it" : "them"}.`);
58
+ }
59
+ const repeatedWords = unique(keywordWords.filter((w, i) => keywordWords.indexOf(w) !== i));
60
+ if (repeatedWords.length > 0) {
61
+ at("info", "keywords/repeated-word", "keywords", `${quoteList(repeatedWords)} ${repeatedWords.length === 1 ? "is" : "are"} written more than once. Single words combine with each other, so phrases rarely need repeating.`);
62
+ }
63
+ if (isEnglish(locale)) {
64
+ const filler = unique(keywordWords.filter((w) => APPLE_FILLER.has(w)));
65
+ if (filler.length > 0) {
66
+ at("info", "keywords/filler", "keywords", `${quoteList(filler)} add little in the keyword field.`);
67
+ }
68
+ const all = unique([...titled, ...keywordWords]);
69
+ const plurals = all.filter((w) => singular(w) !== w && all.includes(singular(w)));
70
+ if (plurals.length > 0) {
71
+ at("info", "keywords/plural-pair", "keywords", `Both singular and plural forms are used (${quoteList(plurals.map((p) => `${singular(p)}/${p}`))}). One form is often enough.`);
72
+ }
73
+ }
74
+ const spare = 100 - charLength(field);
75
+ if (spare >= 10) {
76
+ at("info", "keywords/unused-space", "keywords", `${spare} characters of the keyword field are unused.`);
77
+ }
78
+ }
79
+ if (listing.name !== undefined && listing.name.trim() === "")
80
+ at("error", "required", "name", "The name cannot be empty.");
81
+ return issues;
82
+ }
83
+ export function lintGooglePlay(locale, listing) {
84
+ const issues = [];
85
+ for (const [field, value] of Object.entries(listing)) {
86
+ issues.push(...lintLength("google-play", locale, field, value));
87
+ }
88
+ if (listing.title !== undefined) {
89
+ const banned = PLAY_TITLE_BANNED.filter((re) => re.test(listing.title)).map((re) => listing.title.match(re)[0]);
90
+ if (banned.length > 0) {
91
+ issues.push({
92
+ severity: "warning",
93
+ rule: "play/title-policy",
94
+ store: "google-play",
95
+ locale,
96
+ field: "title",
97
+ message: `Google Play's metadata policy does not allow ranking or promotional words such as ${quoteList(banned)} in the title (fine only when part of your brand).`,
98
+ });
99
+ }
100
+ if (/\p{Extended_Pictographic}/u.test(listing.title)) {
101
+ issues.push({
102
+ severity: "error",
103
+ rule: "play/title-emoji",
104
+ store: "google-play",
105
+ locale,
106
+ field: "title",
107
+ message: "Google Play does not allow emoji in the title.",
108
+ });
109
+ }
110
+ const letters = listing.title.replace(/[^\p{L}]/gu, "");
111
+ if (letters.length >= 6 && letters === letters.toUpperCase() && letters !== letters.toLowerCase()) {
112
+ issues.push({
113
+ severity: "warning",
114
+ rule: "play/title-caps",
115
+ store: "google-play",
116
+ locale,
117
+ field: "title",
118
+ message: "An all-caps title is against Google Play's metadata policy unless it is your brand.",
119
+ });
120
+ }
121
+ }
122
+ return issues;
123
+ }
124
+ export function lintProject(project) {
125
+ const issues = [];
126
+ for (const [locale, listing] of Object.entries(project.listings["app-store"]))
127
+ issues.push(...lintAppStore(locale, listing));
128
+ for (const [locale, listing] of Object.entries(project.listings["google-play"]))
129
+ issues.push(...lintGooglePlay(locale, listing));
130
+ if (project.spec.stores.appStore && Object.keys(project.listings["app-store"]).length === 0) {
131
+ issues.push({
132
+ severity: "info",
133
+ rule: "listings/empty",
134
+ store: "app-store",
135
+ locale: "*",
136
+ message: "No App Store listings yet. Run `appeeky-spec pull` to start from the live store.",
137
+ });
138
+ }
139
+ if (project.spec.stores.googlePlay && Object.keys(project.listings["google-play"]).length === 0) {
140
+ issues.push({
141
+ severity: "info",
142
+ rule: "listings/empty",
143
+ store: "google-play",
144
+ locale: "*",
145
+ message: "No Google Play listings yet. Run `appeeky-spec pull` to start from the live store.",
146
+ });
147
+ }
148
+ return issues;
149
+ }
150
+ export function hasErrors(issues) {
151
+ return issues.some((i) => i.severity === "error");
152
+ }
153
+ function quoteList(items) {
154
+ const quoted = items.map((i) => `"${i}"`);
155
+ if (quoted.length <= 1)
156
+ return quoted.join("");
157
+ return `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
158
+ }
159
+ //# sourceMappingURL=lint.js.map
@@ -0,0 +1,37 @@
1
+ import { type Experiment } from "./experiments.js";
2
+ import { type ReleaseRecord } from "./history.js";
3
+ import type { StoreId } from "./schema.js";
4
+ /** A check this many days past its date with no result has stalled (CI not running, a key missing). */
5
+ export declare const LATE_AFTER_DAYS = 2;
6
+ /** Fewer installs than this in a window: the numbers are a hint, not a result. */
7
+ export declare const FEW_INSTALLS = 100;
8
+ export type LoopState = "measuring" | "due" | "late" | "closed";
9
+ export interface Loop {
10
+ kind: "release" | "experiment";
11
+ id: string;
12
+ store?: StoreId;
13
+ /** What changed, or the hypothesis. */
14
+ what: string;
15
+ /** What decides it. */
16
+ metric: string;
17
+ startedAt: string;
18
+ /** Next check date (YYYY-MM-DD), none once closed. */
19
+ nextCheck?: string;
20
+ state: LoopState;
21
+ /** The latest result: "day 7 won", "won (12 ≥ 10)". */
22
+ result?: string;
23
+ /** The numbers behind the latest result. */
24
+ detail?: string[];
25
+ warnings: string[];
26
+ }
27
+ export declare function releaseLoop(r: ReleaseRecord, now?: Date): Loop;
28
+ export declare function experimentLoop(e: Experiment, now?: Date): Loop;
29
+ export declare function collectLoops(root: string, now?: Date): Promise<Loop[]>;
30
+ /** appeeky/loops.md: what is being measured, what is due, what came out. Rewritten, not appended. */
31
+ export declare function loopsMarkdown(loops: Loop[], now?: Date): string;
32
+ export declare function writeLoops(root: string, now?: Date): Promise<Loop[]>;
33
+ /**
34
+ * Stores with no release being measured: a new listing change there will not mix with an
35
+ * open one. This is what a scheduled agent checks before it proposes anything.
36
+ */
37
+ export declare function freeStores(loops: Loop[], stores: StoreId[]): StoreId[];