desktop: a Fix button on scan and validate findings storebound can fix itself. Findings carry action { method, args } (cli/lib/fix-actions.ts); privacy-labels runs scan.write (scan --write-only, the dialog shows the config diff), Play listing gaps play.listing, a missing App Store localization or copyright apple.listing (editable version only), the App Review contact apple.review_details; each a dry run, the native dialog, then the confirmed run. --write-only is forbidden for MCP tools; tests pin the mapping, the round trip on a fixture and that no fix reaches a submit, release or publish
This commit is contained in:
parent
bd83d036ba
commit
992f5bcc0e
18 changed files with 257 additions and 37 deletions
|
|
@ -730,7 +730,8 @@
|
|||
"store",
|
||||
"threshold",
|
||||
"unity",
|
||||
"write"
|
||||
"write",
|
||||
"write-only"
|
||||
],
|
||||
"serve": [
|
||||
"stdio"
|
||||
|
|
@ -1087,6 +1088,7 @@
|
|||
"with-version",
|
||||
"workspace",
|
||||
"write",
|
||||
"write-only",
|
||||
"xdg-mime"
|
||||
]
|
||||
},
|
||||
|
|
@ -1400,6 +1402,7 @@
|
|||
"scan": [
|
||||
"storebound scan [--dir <project>] [--config <file>] [--store apple|play] [--json] [--no-declarations]",
|
||||
"storebound scan --write also write the detected dataSafety / appPrivacy answers into the config",
|
||||
"storebound scan --write-only [--dry] only that write (with --dry: the config diff), no report (desktop Fix button)",
|
||||
"storebound scan --originality [--against <folders>] [--threshold 0.7] also the 4.3 / Play repetitive content"
|
||||
],
|
||||
"shots capture": [
|
||||
|
|
|
|||
23
cli/lib/fix-actions.ts
Normal file
23
cli/lib/fix-actions.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Machine-readable fixes of scan and validate findings: the desktop's "Fix" button runs `action.method` with
|
||||
// `action.args` as a write (dry run, the native dialog showing it, then the real run with a host-minted token), so the
|
||||
// window never parses a finding's text. Only fixes an existing command fully determines get an action; the rest has
|
||||
// none. FIX_METHODS is the whole list: local config writes and store text pushes, never a submit, release or publish
|
||||
// (tests/fix-actions.test.ts).
|
||||
|
||||
export interface FixAction { method: FixMethod; args: Record<string, string> }
|
||||
|
||||
export const FIX_METHODS = ['scan.write', 'play.listing', 'apple.listing', 'apple.review_details'] as const;
|
||||
export type FixMethod = typeof FIX_METHODS[number];
|
||||
|
||||
/** Scan rules `storebound scan --write` fixes: the detected Data safety / App Privacy answers go into the config. */
|
||||
export const SCAN_WRITE_RULES = new Set(['privacy-labels']);
|
||||
|
||||
/** The fix of one scan finding: `scan --write` while it would change the config (detected answers only). */
|
||||
export function scanFixAction(rule: string, o: { configPath: string | null; declarationsChanged: boolean }): FixAction | undefined {
|
||||
return SCAN_WRITE_RULES.has(rule) && o.configPath && o.declarationsChanged ? { method: 'scan.write', args: {} } : undefined;
|
||||
}
|
||||
|
||||
/** Validate findings fixed by pushing the listing files / config to the store (text only, never submitted). */
|
||||
export const fixPlayListing = (): FixAction => ({ method: 'play.listing', args: {} });
|
||||
export const fixAppleListing = (version: string): FixAction => ({ method: 'apple.listing', args: { version } });
|
||||
export const fixReviewDetails = (): FixAction => ({ method: 'apple.review_details', args: {} });
|
||||
|
|
@ -5,6 +5,7 @@ import { existsSync, readFileSync } from 'node:fs';
|
|||
import { join, relative, resolve } from 'node:path';
|
||||
import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config';
|
||||
import { reviewDemoAccount } from '../credentials';
|
||||
import { scanFixAction } from '../fix-actions';
|
||||
import { proposeDeclarations, type Proposal } from './declare';
|
||||
import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project';
|
||||
import type { OriginalityReport } from '../originality';
|
||||
|
|
@ -62,6 +63,10 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise
|
|||
return { type: d.type, missing: [...new Set(missing)].join(', '), evidence: d.evidence };
|
||||
}).filter(d => d.missing) : [];
|
||||
const findings = scanRules({ facts, config, configFile, listings, demoAccount: !!reviewDemoAccount(dir), labelGaps: declarations.labelGaps, dataMerges });
|
||||
for (const f of findings) {
|
||||
const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed });
|
||||
if (action) f.action = action;
|
||||
}
|
||||
const counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
|
||||
for (const f of findings) { if (f.accepted) counts.accepted++; else counts[f.severity]++; }
|
||||
const notes: string[] = [];
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
|
||||
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
|
||||
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
|
||||
import type { FixAction } from '../fix-actions';
|
||||
import { templateBlockers } from '../template-answers';
|
||||
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
||||
import type { ShipConfig } from '../config';
|
||||
|
|
@ -62,6 +63,8 @@ export interface ScanFinding {
|
|||
ref: string;
|
||||
/** Owner accepted it (`scan.ignore` in the config) with this reason. */
|
||||
accepted?: string;
|
||||
/** The desktop's Fix button: a write fully determined by an existing command (lib/fix-actions.ts). */
|
||||
action?: FixAction;
|
||||
}
|
||||
|
||||
export interface ScanInput {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Store readiness rules for `storebound validate`. Pure: the command collects a snapshot from both APIs (read-only),
|
||||
// these functions turn it into an ordered list of findings. Order = the order you would fix things in.
|
||||
|
||||
import { type FixAction, fixAppleListing, fixPlayListing, fixReviewDetails } from './fix-actions';
|
||||
|
||||
export type Level = 'blocker' | 'warn' | 'ok';
|
||||
|
||||
/** Screenshot sets App Store Connect takes instead of a required one (App Store Connect Help, "Screenshot
|
||||
|
|
@ -13,7 +15,9 @@ export const ACCEPTED_INSTEAD: Record<string, string[]> = {
|
|||
APP_IPAD_PRO_3GEN_129: ['APP_IPAD_PRO_129'],
|
||||
APP_IPAD_PRO_129: ['APP_IPAD_PRO_3GEN_129'],
|
||||
};
|
||||
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string }
|
||||
export interface Finding { store: 'apple' | 'play'; level: Level; code: string; msg: string; fix?: string;
|
||||
/** The desktop's Fix button: a write fully determined by an existing command (lib/fix-actions.ts). */
|
||||
action?: FixAction }
|
||||
|
||||
export interface UrlCheck { url: string; status: number | null; error?: string; body?: string }
|
||||
|
||||
|
|
@ -168,12 +172,14 @@ export function copyrightRules(s: Pick<AppleSnapshot, 'version' | 'configCopyrig
|
|||
const live = s.version.copyright?.trim();
|
||||
const cfg = s.configCopyright?.trim();
|
||||
if (live) return [{ store: 'apple', level: cfg ? 'ok' : 'warn', code: 'copyright', msg: cfg ? `copyright on the store: ${live}` : `copyright on the store: ${live}; ${COPYRIGHT_MISSING}`, fix: cfg ? undefined : 'set app.copyright in storebound.config.ts' }];
|
||||
return [{ store: 'apple', level: 'blocker', code: 'copyright', msg: `version ${s.version.versionString} has no copyright on the store${cfg ? ` (config has '${cfg}': storebound apple listing sends it)` : `; ${COPYRIGHT_MISSING}`}`, fix: cfg ? 'storebound apple listing --version x.y.z' : 'set app.copyright in storebound.config.ts, then storebound apple listing' }];
|
||||
return [{ store: 'apple', level: 'blocker', code: 'copyright', msg: `version ${s.version.versionString} has no copyright on the store${cfg ? ` (config has '${cfg}': storebound apple listing sends it)` : `; ${COPYRIGHT_MISSING}`}`, fix: cfg ? 'storebound apple listing --version x.y.z' : 'set app.copyright in storebound.config.ts, then storebound apple listing',
|
||||
...(cfg && EDITABLE.includes(s.version.state) ? { action: fixAppleListing(s.version.versionString) } : {}) }];
|
||||
}
|
||||
|
||||
export function appleRules(s: AppleSnapshot): Finding[] {
|
||||
const f: Finding[] = [];
|
||||
const add = (level: Level, code: string, msg: string, fix?: string) => f.push({ store: 'apple', level, code, msg, fix });
|
||||
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction) => f.push({ store: 'apple', level, code, msg, fix, ...(action ? { action } : {}) });
|
||||
const listing = s.version && EDITABLE.includes(s.version.state) ? fixAppleListing(s.version.versionString) : undefined;
|
||||
|
||||
if (!s.version) add('blocker', 'version', 'no App Store version being prepared', 'storebound apple listing --version x.y.z');
|
||||
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${s.version.versionString}: ${s.version.state}`);
|
||||
|
|
@ -193,7 +199,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
}
|
||||
|
||||
const have = new Map(s.locales.map(l => [l.locale, l] as const));
|
||||
for (const loc of s.expectedLocales) if (!have.has(loc)) add('blocker', 'locale', `${loc}: no localization on the version`, 'storebound apple listing');
|
||||
for (const loc of s.expectedLocales) if (!have.has(loc)) add('blocker', 'locale', `${loc}: no localization on the version`, 'storebound apple listing', listing);
|
||||
for (const l of s.locales) {
|
||||
const miss = (['description', 'keywords', 'subtitle'] as const).filter(k => !l[k]);
|
||||
if (miss.length) add('blocker', 'metadata', `${l.locale}: missing ${miss.join(', ')}`, 'store/appstore.md, then storebound apple listing');
|
||||
|
|
@ -207,10 +213,10 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
if (s.locales.length && !f.some(x => x.code === 'metadata' || x.code === 'screenshots' || x.code === 'locale')) add('ok', 'metadata', `${s.locales.length} locales with text and screenshots`);
|
||||
|
||||
const r = s.reviewDetail;
|
||||
if (!r) add('blocker', 'review-details', 'no App Review information', 'storebound apple review-details');
|
||||
if (!r) add('blocker', 'review-details', 'no App Review information', 'storebound apple review-details', fixReviewDetails());
|
||||
else {
|
||||
const miss = (['contactFirstName', 'contactLastName', 'contactPhone', 'contactEmail'] as const).filter(k => !r[k]);
|
||||
if (miss.length) add('blocker', 'review-details', `review contact missing ${miss.join(', ')}`, 'storebound apple review-details');
|
||||
if (miss.length) add('blocker', 'review-details', `review contact missing ${miss.join(', ')}`, 'storebound apple review-details', fixReviewDetails());
|
||||
else if (r.demoAccountRequired && !r.demoAccountName) add('blocker', 'review-details', 'demo account required but not given');
|
||||
else add('ok', 'review-details', 'review contact set');
|
||||
if (!r.notes) add('warn', 'review-notes', 'no review notes: say where IAPs, Restore Purchases, ads and the privacy policy are', 'apple.info.reviewNotes');
|
||||
|
|
@ -257,14 +263,14 @@ export const normAdsLine = (l: string) => l.split('#')[0]!.split(',').map(p => p
|
|||
|
||||
export function playRules(s: PlaySnapshot): Finding[] {
|
||||
const f: Finding[] = [];
|
||||
const add = (level: Level, code: string, msg: string, fix?: string) => f.push({ store: 'play', level, code, msg, fix });
|
||||
const add = (level: Level, code: string, msg: string, fix?: string, action?: FixAction) => f.push({ store: 'play', level, code, msg, fix, ...(action ? { action } : {}) });
|
||||
const have = new Map(s.listings.map(l => [l.language, l] as const));
|
||||
const fallback: Record<string, string[]> = {};
|
||||
if (!have.has(s.defaultLanguage)) add('blocker', 'listing', `no listing for the default language ${s.defaultLanguage}`, 'storebound play listing');
|
||||
for (const lang of s.expectedLanguages) if (!have.has(lang)) add('blocker', 'listing', `${lang}: no listing`, 'storebound play listing');
|
||||
if (!have.has(s.defaultLanguage)) add('blocker', 'listing', `no listing for the default language ${s.defaultLanguage}`, 'storebound play listing', fixPlayListing());
|
||||
for (const lang of s.expectedLanguages) if (!have.has(lang)) add('blocker', 'listing', `${lang}: no listing`, 'storebound play listing', fixPlayListing());
|
||||
for (const l of s.listings) {
|
||||
const miss = (['title', 'shortDescription', 'fullDescription'] as const).filter(k => !l[k]);
|
||||
if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing');
|
||||
if (miss.length) add('blocker', 'listing', `${l.language}: missing ${miss.join(', ')}`, 'storebound play listing', fixPlayListing());
|
||||
for (const [type, n] of Object.entries(playRequiredImages(s.tablets))) {
|
||||
if ((l.images[type] ?? 0) >= n) continue;
|
||||
// Play shows the default language's graphics in a language that has none of its own (store-pitfalls 45): only
|
||||
|
|
|
|||
|
|
@ -424,7 +424,7 @@ export const TOOLS: ToolDef[] = [
|
|||
];
|
||||
|
||||
/** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */
|
||||
export const FORBIDDEN_ARGS = ['--write', '--yes', '--manual-release', '--cancel-submission', '--production', 'rollout', 'resume', 'complete',
|
||||
export const FORBIDDEN_ARGS = ['--write', '--write-only', '--yes', '--manual-release', '--cancel-submission', '--production', 'rollout', 'resume', 'complete',
|
||||
'--submit-review', '--remove', '--invite', 'testers', 'send', 'start', 'stop'];
|
||||
|
||||
/**
|
||||
|
|
|
|||
54
cli/scan.ts
54
cli/scan.ts
|
|
@ -1,6 +1,7 @@
|
|||
// Offline, read-only scan of the project itself for App Store and Google Play rejection risks, before you submit.
|
||||
// storebound scan [--dir <project>] [--config <file>] [--store apple|play] [--json] [--no-declarations]
|
||||
// storebound scan --write also write the detected dataSafety / appPrivacy answers into the config
|
||||
// storebound scan --write-only [--dry] only that write (with --dry: the config diff), no report (desktop Fix button)
|
||||
// storebound scan --originality [--against <folders>] [--threshold 0.7] also the 4.3 / Play repetitive content
|
||||
// check against your other apps (storebound originality; folders default to originality.against in the config)
|
||||
// Reads the project files (Xcode project, Info.plist, entitlements, privacy manifests, Podfile.lock, Package.resolved,
|
||||
|
|
@ -16,7 +17,8 @@ import type { Args } from './lib/args';
|
|||
import { CONFIG_NAMES } from './lib/config';
|
||||
import { log, printJson, warn } from './lib/log';
|
||||
import { upsertBlock } from './lib/scan/config-edit';
|
||||
import { addOriginality, formatScan, runScan } from './lib/scan';
|
||||
import { addOriginality, formatScan, runScan, type ScanReport } from './lib/scan';
|
||||
import { compactDiff } from './lib/diff';
|
||||
import { compareAgainst } from './originality';
|
||||
|
||||
/** Project folder: --dir, else the folder of the nearest storebound.config.* above the working directory, else it. */
|
||||
|
|
@ -48,25 +50,43 @@ export async function run(args: Args): Promise<void> {
|
|||
report.counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 };
|
||||
for (const f of report.findings) { if (f.accepted) report.counts.accepted++; else report.counts[f.severity]++; }
|
||||
} else if (store) throw new Error('--store apple | play');
|
||||
if (args.has('json')) printJson(report);
|
||||
else console.log(formatScan(report, { declarations: !args.has('no-declarations') }));
|
||||
// --write-only (the desktop's Fix button, scan.write): only the write and its diff, no report, no blocker exit
|
||||
const writeOnly = args.has('write-only');
|
||||
if (!writeOnly) {
|
||||
if (args.has('json')) printJson(report);
|
||||
else console.log(formatScan(report, { declarations: !args.has('no-declarations') }));
|
||||
}
|
||||
|
||||
if (args.has('write')) {
|
||||
if (args.has('write') || writeOnly) {
|
||||
const d = report.declarations;
|
||||
if (!configPath) warn('scan', '--write: no storebound.config.ts in this project (storebound init creates one)');
|
||||
else if (!d.changed) log('scan', `--write: nothing to change in ${report.config}`);
|
||||
else if (args.has('dry')) log('scan', `--dry: would write ${[(d.diff.some(x => x.change !== 'kept' && x.change !== 'same') || d.accountCreationChange) && 'dataSafety', (d.appPrivacyChange || d.appPrivacyTypes) && 'appPrivacy'].filter(Boolean).join(' + ')} into ${report.config}`);
|
||||
if (!configPath) {
|
||||
if (writeOnly) throw new Error('no storebound.config.ts in this project (storebound init creates one)');
|
||||
warn('scan', '--write: no storebound.config.ts in this project (storebound init creates one)');
|
||||
} else if (!d.changed) log('scan', `--write: nothing to change in ${report.config}`);
|
||||
else {
|
||||
let text = readFileSync(configPath, 'utf8');
|
||||
const ds = d.dataSafety!;
|
||||
const missing = (['encryptedInTransit'] as const).filter(k => typeof ds[k] !== 'boolean');
|
||||
if (d.diff.some(x => x.change === 'add' || x.change === 'merge') || d.accountCreationChange)
|
||||
text = upsertBlock(text, configPath, 'dataSafety', ds, missing.length ? `storebound scan: answer ${missing.join(' and ')} (ask the owner; store-declarations skill)` : undefined);
|
||||
if (d.appPrivacyChange || d.appPrivacyTypes) text = upsertBlock(text, configPath, 'appPrivacy', d.appPrivacy,
|
||||
d.appPrivacyTypes ? 'storebound scan: types proposed from the detected SDKs; answer linked / tracking per type (ask the owner; store-declarations skill)' : undefined);
|
||||
writeFileSync(configPath, text);
|
||||
log('scan', `wrote the detected answers into ${report.config}${missing.length ? `; still to answer: dataSafety.${missing.join(', dataSafety.')}` : ''}. Review the diff (git diff) before pushing it to a store.`);
|
||||
const before = readFileSync(configPath, 'utf8');
|
||||
const { text, missing } = declarationsWrite(before, configPath, d);
|
||||
const what = [(d.diff.some(x => x.change !== 'kept' && x.change !== 'same') || d.accountCreationChange) && 'dataSafety', (d.appPrivacyChange || d.appPrivacyTypes) && 'appPrivacy'].filter(Boolean).join(' + ');
|
||||
if (args.has('dry')) {
|
||||
log('scan', `--dry: would write ${what} into ${report.config}`);
|
||||
console.log([`--- ${report.config}`, ...compactDiff(before, text)].join('\n'));
|
||||
} else {
|
||||
writeFileSync(configPath, text);
|
||||
log('scan', `wrote the detected answers into ${report.config}${missing.length ? `; still to answer: dataSafety.${missing.join(', dataSafety.')}` : ''}. Review the diff (git diff) before pushing it to a store.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (report.counts.blocker) process.exitCode = 1;
|
||||
if (report.counts.blocker && !writeOnly) process.exitCode = 1;
|
||||
}
|
||||
|
||||
/** The config text with the detected answers written in (dataSafety / appPrivacy blocks; comments and order kept). */
|
||||
export function declarationsWrite(before: string, configPath: string, d: ScanReport['declarations']): { text: string; missing: string[] } {
|
||||
let text = before;
|
||||
const ds = d.dataSafety!;
|
||||
const missing = (['encryptedInTransit'] as const).filter(k => typeof ds[k] !== 'boolean');
|
||||
if (d.diff.some(x => x.change === 'add' || x.change === 'merge') || d.accountCreationChange)
|
||||
text = upsertBlock(text, configPath, 'dataSafety', ds, missing.length ? `storebound scan: answer ${missing.join(' and ')} (ask the owner; store-declarations skill)` : undefined);
|
||||
if (d.appPrivacyChange || d.appPrivacyTypes) text = upsertBlock(text, configPath, 'appPrivacy', d.appPrivacy,
|
||||
d.appPrivacyTypes ? 'storebound scan: types proposed from the detected SDKs; answer linked / tracking per type (ask the owner; store-declarations skill)' : undefined);
|
||||
return { text, missing };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,6 +65,11 @@ export const JSON_WRITES = new Set(['console_fill', 'owner_apple_submit', 'owner
|
|||
/** Write tools whose command may ask its own question (confirm()): a verified host token answers it, as for owner actions. */
|
||||
const TOKEN_ANSWERS = new Set(['console_fill']);
|
||||
|
||||
/** The one desktop exception to FORBIDDEN_ARGS: `scan --write-only` (scan.write, the Fix button of a privacy-labels
|
||||
* finding) writes the detected declarations into the config, only after the native dialog showed its diff and the
|
||||
* host minted a token. The MCP server (AI tools) never gets it (tests/fix-actions.test.ts). */
|
||||
export const DESKTOP_LOCAL_WRITES: Record<string, readonly string[]> = { scan_write: ['--write-only'] };
|
||||
|
||||
/**
|
||||
* Methods only the desktop profile has (not MCP tools): offline reads of the project's own files, local writes the
|
||||
* owner confirms in a native dialog, and `console.fill`. That one is a write: its dry run returns the answers it would
|
||||
|
|
@ -132,6 +137,8 @@ export const DESKTOP_ONLY: ToolDef[] = [
|
|||
props: { from: { type: 'string', pattern: ART_FROM_RE.source, description: 'The picked file (absolute path)' },
|
||||
to: { type: 'string', pattern: ART_TO_RE.source, description: 'The image to replace, relative to the app folder' } },
|
||||
required: ['from', 'to'], args: i => ['--from', String(i.from), '--to', String(i.to)] },
|
||||
{ name: 'scan_write', title: 'Write the detected declarations', command: ['scan', DESKTOP_LOCAL_WRITES.scan_write![0]!], write: true, network: false, idempotent: true,
|
||||
description: 'The Fix of a scan finding (privacy-labels): writes the Data safety / App Privacy answers the scan detected from the SDKs into the config (dataSafety / appPrivacy blocks; the rest of the file kept). The dry run shows the config diff; "ask the owner" answers stay questions. Nothing goes to a store and nothing is committed.' },
|
||||
{ name: 'prices_current', title: 'Current prices', command: ['prices', 'export'], write: false, network: true,
|
||||
description: 'Read-only: the current price of every product on both stores per region (Apple manual prices, with with_automatic also the equalized ones). Writes nothing.',
|
||||
props: { store: { type: 'string', enum: ['apple', 'play'] }, with_automatic: { type: 'boolean', default: false } },
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ export function scanAsFindings(f: ScanFinding[], stores: { apple: boolean; play:
|
|||
return f.filter(x => !x.accepted && x.severity !== 'low' && stores[x.store]).map(x => ({
|
||||
store: x.store, level: x.severity === 'blocker' ? 'blocker' as const : 'warn' as const, code: `scan-${x.rule}`,
|
||||
msg: `${x.guideline}: ${x.title}${x.evidence[0] ? ` (${x.evidence[0].file}:${x.evidence[0].line})` : ''}`,
|
||||
fix: `${x.fix} · ${x.skill} skill`,
|
||||
fix: `${x.fix} · ${x.skill} skill`, ...(x.action ? { action: x.action } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue