@qobi/seocode 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 +21 -0
- package/dist/src/billing/polar.js +127 -0
- package/dist/src/billing/repo-limits.js +60 -0
- package/dist/src/billing/subscription-service.js +123 -0
- package/dist/src/billing/tier.js +14 -0
- package/dist/src/billing/types.js +1 -0
- package/dist/src/cli/discover.js +62 -0
- package/dist/src/cli/fix.js +24 -0
- package/dist/src/cli/format-terminal.js +66 -0
- package/dist/src/cli/index.js +204 -0
- package/dist/src/cli/init.js +48 -0
- package/dist/src/cli/staged.js +24 -0
- package/dist/src/config/seocode-config.js +97 -0
- package/dist/src/engine/page-detector.js +82 -0
- package/dist/src/engine/pr-delta.js +31 -0
- package/dist/src/engine/rule-engine.js +112 -0
- package/dist/src/engine/rule-loader.js +35 -0
- package/dist/src/engine/rules/declarative-evaluator.js +105 -0
- package/dist/src/engine/rules/heading-rules.js +43 -0
- package/dist/src/engine/rules/helpers.js +16 -0
- package/dist/src/engine/rules/image-rules.js +82 -0
- package/dist/src/engine/rules/index.js +28 -0
- package/dist/src/engine/rules/link-rules.js +39 -0
- package/dist/src/engine/rules/meta-rules.js +34 -0
- package/dist/src/engine/rules/performance-rules.js +24 -0
- package/dist/src/engine/rules/schema-rules.js +228 -0
- package/dist/src/engine/rules/suggest.js +103 -0
- package/dist/src/engine/rules/technical-rules.js +100 -0
- package/dist/src/parsers/frameworks/ast-value.js +83 -0
- package/dist/src/parsers/frameworks/astro.js +107 -0
- package/dist/src/parsers/frameworks/index.js +36 -0
- package/dist/src/parsers/frameworks/nextjs.js +170 -0
- package/dist/src/parsers/frameworks/remix.js +130 -0
- package/dist/src/parsers/frameworks/roles.js +63 -0
- package/dist/src/parsers/frameworks/types.js +1 -0
- package/dist/src/parsers/html-parser.js +118 -0
- package/dist/src/parsers/index.js +62 -0
- package/dist/src/parsers/jsx-parser.js +174 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/types/worker-env.js +1 -0
- package/package.json +75 -0
- package/rules/seo-rules.json +3414 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SEOCode
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Polar API client — used at activation time to PROVE a person
|
|
3
|
+
* actually paid before we grant a plan. Never trust a plan/subscription id that
|
|
4
|
+
* arrives from the browser; always re-fetch it here with the org access token.
|
|
5
|
+
*
|
|
6
|
+
* Response shapes are parsed defensively: Polar's fields are accessed with
|
|
7
|
+
* fallbacks so a minor schema change degrades gracefully rather than throwing.
|
|
8
|
+
* Confirm the exact field names against a real API response the first time.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_BASE = 'https://api.polar.sh';
|
|
11
|
+
const VALID_PLANS = new Set(['free', 'studio']);
|
|
12
|
+
const ACTIVE_STATUSES = new Set(['active', 'trialing']);
|
|
13
|
+
function base(env) {
|
|
14
|
+
return (env.POLAR_API_BASE ?? DEFAULT_BASE).replace(/\/$/, '');
|
|
15
|
+
}
|
|
16
|
+
async function polarGet(env, path) {
|
|
17
|
+
const res = await fetch(`${base(env)}${path}`, {
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Bearer ${env.POLAR_API_TOKEN}`,
|
|
20
|
+
Accept: 'application/json',
|
|
21
|
+
'User-Agent': 'SEOCode-Activation',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
// Log the failure (status + path only — never the token) so activation
|
|
26
|
+
// problems are traceable. 404s are expected (unknown id/email); warn on the rest.
|
|
27
|
+
console.warn(`[WARN] Polar API ${res.status} for ${path}`);
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return res.json();
|
|
31
|
+
}
|
|
32
|
+
/** Resolves the internal plan from a Polar product object. */
|
|
33
|
+
export function resolvePlan(product) {
|
|
34
|
+
const fromMeta = (product?.metadata?.plan ?? '').toString().toLowerCase();
|
|
35
|
+
if (VALID_PLANS.has(fromMeta))
|
|
36
|
+
return fromMeta;
|
|
37
|
+
const fromName = (product?.name ?? '').toString().toLowerCase();
|
|
38
|
+
for (const p of ['studio']) {
|
|
39
|
+
if (fromName.includes(p))
|
|
40
|
+
return p;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
function normalizeSubscription(sub) {
|
|
45
|
+
if (!sub)
|
|
46
|
+
return null;
|
|
47
|
+
const plan = resolvePlan(sub.product);
|
|
48
|
+
if (!plan) {
|
|
49
|
+
// Expected misconfiguration: a paid product without a resolvable plan.
|
|
50
|
+
// Log it (id only) so it can be traced to the product's metadata.
|
|
51
|
+
console.warn(`[WARN] Polar subscription ${sub.id ?? '?'} has no resolvable plan — set the product's metadata.plan (pro|team|agency)`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
const status = (sub.status ?? '').toString().toLowerCase();
|
|
55
|
+
const email = sub.customer?.email ?? sub.customer_email ?? sub.user?.email ?? '';
|
|
56
|
+
return {
|
|
57
|
+
plan,
|
|
58
|
+
subscriptionId: sub.id ?? '',
|
|
59
|
+
customerEmail: email.toLowerCase(),
|
|
60
|
+
githubOrgHint: sub.custom_field_data?.github_org,
|
|
61
|
+
active: ACTIVE_STATUSES.has(status),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a paid subscription from a checkout id (the `{CHECKOUT_ID}` Polar
|
|
66
|
+
* puts in the success URL). This is the same-session / link-in-receipt path.
|
|
67
|
+
*/
|
|
68
|
+
export async function activationFromCheckout(env, checkoutId) {
|
|
69
|
+
const checkout = await polarGet(env, `/v1/checkouts/${encodeURIComponent(checkoutId)}`);
|
|
70
|
+
if (!checkout)
|
|
71
|
+
return null;
|
|
72
|
+
// A completed checkout links to a subscription — prefer resolving that directly.
|
|
73
|
+
const subId = checkout.subscription_id ?? checkout.subscription?.id;
|
|
74
|
+
if (subId) {
|
|
75
|
+
const info = await activationFromSubscriptionId(env, subId);
|
|
76
|
+
if (info)
|
|
77
|
+
return info;
|
|
78
|
+
}
|
|
79
|
+
// The subscription may not be linked to the checkout yet (Polar creates it
|
|
80
|
+
// slightly async). Fall back to resolving the real subscription by the
|
|
81
|
+
// checkout's customer email, so we still capture a proper subscription id.
|
|
82
|
+
const email = (checkout.customer_email ?? checkout.customer?.email ?? '').toLowerCase();
|
|
83
|
+
if (email) {
|
|
84
|
+
const byEmail = await activationFromEmails(env, [email]);
|
|
85
|
+
if (byEmail?.active)
|
|
86
|
+
return byEmail;
|
|
87
|
+
}
|
|
88
|
+
// Last resort: use fields on the checkout itself (subscription id may be blank).
|
|
89
|
+
const plan = resolvePlan(checkout.product);
|
|
90
|
+
if (!plan)
|
|
91
|
+
return null;
|
|
92
|
+
const status = (checkout.status ?? '').toString().toLowerCase();
|
|
93
|
+
return {
|
|
94
|
+
plan,
|
|
95
|
+
subscriptionId: subId ?? '',
|
|
96
|
+
customerEmail: email,
|
|
97
|
+
githubOrgHint: checkout.custom_field_data?.github_org,
|
|
98
|
+
active: status === 'confirmed' || status === 'succeeded' || status === 'paid',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export async function activationFromSubscriptionId(env, subscriptionId) {
|
|
102
|
+
const sub = await polarGet(env, `/v1/subscriptions/${encodeURIComponent(subscriptionId)}`);
|
|
103
|
+
return normalizeSubscription(sub);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Cross-device fallback: the user signs in with GitHub on any device and we
|
|
107
|
+
* match their verified GitHub email to a Polar customer's active subscription.
|
|
108
|
+
* Returns the first active paid subscription found across the given emails.
|
|
109
|
+
*/
|
|
110
|
+
export async function activationFromEmails(env, emails) {
|
|
111
|
+
for (const email of emails) {
|
|
112
|
+
const customers = await polarGet(env, `/v1/customers/?email=${encodeURIComponent(email)}`);
|
|
113
|
+
const items = customers?.items ?? customers?.result?.items ?? [];
|
|
114
|
+
for (const customer of items) {
|
|
115
|
+
if (!customer?.id)
|
|
116
|
+
continue;
|
|
117
|
+
const subs = await polarGet(env, `/v1/subscriptions/?customer_id=${encodeURIComponent(customer.id)}&active=true`);
|
|
118
|
+
const subItems = subs?.items ?? subs?.result?.items ?? [];
|
|
119
|
+
for (const sub of subItems) {
|
|
120
|
+
const info = normalizeSubscription(sub);
|
|
121
|
+
if (info?.active)
|
|
122
|
+
return info;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository access model (2-tier):
|
|
3
|
+
*
|
|
4
|
+
* Developer (free) → unlimited PUBLIC repos + 1 PRIVATE repo
|
|
5
|
+
* Studio ($19/mo) → unlimited public + unlimited private, across unlimited orgs
|
|
6
|
+
*
|
|
7
|
+
* Public repositories are always covered on every tier. The only gate is the
|
|
8
|
+
* number of PRIVATE repositories a Free org may review.
|
|
9
|
+
*/
|
|
10
|
+
export const PLAN_PRIVATE_REPO_LIMITS = {
|
|
11
|
+
free: 1,
|
|
12
|
+
studio: Infinity,
|
|
13
|
+
};
|
|
14
|
+
const KEY_PREFIX = 'privrepos:';
|
|
15
|
+
function reposKey(orgLogin) {
|
|
16
|
+
return `${KEY_PREFIX}${orgLogin.toLowerCase()}`;
|
|
17
|
+
}
|
|
18
|
+
/** Private repos an org has enrolled, in first-seen order. */
|
|
19
|
+
export async function getEnrolledRepos(orgLogin, kv) {
|
|
20
|
+
const raw = await kv.get(reposKey(orgLogin));
|
|
21
|
+
return raw ? JSON.parse(raw) : [];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Decides whether a repo may be reviewed. Public repos are always allowed.
|
|
25
|
+
* Private repos are enrolled first-come up to the plan's private cap.
|
|
26
|
+
*/
|
|
27
|
+
export async function checkRepoAccess(orgLogin, repo, isPrivate, plan, kv) {
|
|
28
|
+
const limit = PLAN_PRIVATE_REPO_LIMITS[plan];
|
|
29
|
+
// Public repositories are covered on every tier — no slot consumed.
|
|
30
|
+
if (!isPrivate) {
|
|
31
|
+
return { allowed: true, isPrivate: false, limit, count: 0 };
|
|
32
|
+
}
|
|
33
|
+
const enrolled = await getEnrolledRepos(orgLogin, kv);
|
|
34
|
+
const name = repo.toLowerCase();
|
|
35
|
+
if (enrolled.includes(name)) {
|
|
36
|
+
return { allowed: true, isPrivate: true, limit, count: enrolled.length };
|
|
37
|
+
}
|
|
38
|
+
if (enrolled.length < limit) {
|
|
39
|
+
enrolled.push(name);
|
|
40
|
+
await kv.put(reposKey(orgLogin), JSON.stringify(enrolled));
|
|
41
|
+
return { allowed: true, isPrivate: true, limit, count: enrolled.length };
|
|
42
|
+
}
|
|
43
|
+
return { allowed: false, isPrivate: true, limit, count: enrolled.length };
|
|
44
|
+
}
|
|
45
|
+
/** Frees a private repo's slot (e.g. when the app is removed from it). */
|
|
46
|
+
export async function releaseRepo(orgLogin, repo, kv) {
|
|
47
|
+
const key = reposKey(orgLogin);
|
|
48
|
+
const enrolled = await getEnrolledRepos(orgLogin, kv);
|
|
49
|
+
const next = enrolled.filter(r => r !== repo.toLowerCase());
|
|
50
|
+
if (next.length === enrolled.length)
|
|
51
|
+
return;
|
|
52
|
+
if (next.length === 0)
|
|
53
|
+
await kv.delete(key);
|
|
54
|
+
else
|
|
55
|
+
await kv.put(key, JSON.stringify(next));
|
|
56
|
+
}
|
|
57
|
+
/** Frees every private slot for an org (e.g. when the app is uninstalled). */
|
|
58
|
+
export async function clearRepos(orgLogin, kv) {
|
|
59
|
+
await kv.delete(reposKey(orgLogin));
|
|
60
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const KEY_PREFIX = 'sub:';
|
|
2
|
+
/**
|
|
3
|
+
* Maps the plan name from GitHub Marketplace to the internal Plan type.
|
|
4
|
+
* Names must match exactly what is configured in the Marketplace listing
|
|
5
|
+
* (comparison is case-insensitive via .toLowerCase()).
|
|
6
|
+
*/
|
|
7
|
+
const PLAN_NAME_MAP = {
|
|
8
|
+
'developer': 'free',
|
|
9
|
+
'free': 'free',
|
|
10
|
+
'studio': 'studio',
|
|
11
|
+
'team': 'studio', // in case the Polar product is named "Studio / Team"
|
|
12
|
+
};
|
|
13
|
+
function planKey(orgLogin) {
|
|
14
|
+
// Always lowercase — prevents duplicate records from GitHub payload case variations
|
|
15
|
+
return `${KEY_PREFIX}${orgLogin.toLowerCase()}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Returns the organisation's subscription.
|
|
19
|
+
* Falls back to a free plan if the org has never been seen — e.g. they
|
|
20
|
+
* installed via the GitHub App directly without going through Marketplace.
|
|
21
|
+
*/
|
|
22
|
+
export async function getSubscription(orgLogin, kv) {
|
|
23
|
+
const raw = await kv.get(planKey(orgLogin));
|
|
24
|
+
if (raw)
|
|
25
|
+
return JSON.parse(raw);
|
|
26
|
+
return {
|
|
27
|
+
plan: 'free',
|
|
28
|
+
installedAt: new Date().toISOString(),
|
|
29
|
+
updatedAt: new Date().toISOString(),
|
|
30
|
+
marketplacePlanId: 0,
|
|
31
|
+
sender: 'unknown',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Creates or updates the subscription for an org based on a marketplace_purchase event.
|
|
36
|
+
* Idempotent — safe to call multiple times with the same payload (GitHub retries).
|
|
37
|
+
*/
|
|
38
|
+
export async function upsertSubscription(payload, kv) {
|
|
39
|
+
const orgLogin = payload.marketplace_purchase.account.login;
|
|
40
|
+
const planName = payload.marketplace_purchase.plan.name.toLowerCase();
|
|
41
|
+
const plan = PLAN_NAME_MAP[planName] ?? 'free';
|
|
42
|
+
const raw = await kv.get(planKey(orgLogin));
|
|
43
|
+
const existing = raw ? JSON.parse(raw) : null;
|
|
44
|
+
const subscription = {
|
|
45
|
+
plan,
|
|
46
|
+
// Preserve original install date on plan changes — only set on first purchase
|
|
47
|
+
installedAt: existing?.installedAt ?? new Date().toISOString(),
|
|
48
|
+
updatedAt: new Date().toISOString(),
|
|
49
|
+
marketplacePlanId: payload.marketplace_purchase.plan.id,
|
|
50
|
+
sender: payload.sender.login,
|
|
51
|
+
};
|
|
52
|
+
await kv.put(planKey(orgLogin), JSON.stringify(subscription));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Provider-agnostic activation. Sets an org's plan directly, without a
|
|
56
|
+
* GitHub Marketplace payload — used by external payment providers (e.g. Polar)
|
|
57
|
+
* whose checkout captures the target org via a custom field.
|
|
58
|
+
*
|
|
59
|
+
* Preserves installedAt on repeat calls, same as upsertSubscription.
|
|
60
|
+
* marketplacePlanId is 0 for non-Marketplace subscriptions.
|
|
61
|
+
*/
|
|
62
|
+
export async function setPlan(orgLogin, plan, kv, sender = 'unknown') {
|
|
63
|
+
const raw = await kv.get(planKey(orgLogin));
|
|
64
|
+
const existing = raw ? JSON.parse(raw) : null;
|
|
65
|
+
const subscription = {
|
|
66
|
+
plan,
|
|
67
|
+
installedAt: existing?.installedAt ?? new Date().toISOString(),
|
|
68
|
+
updatedAt: new Date().toISOString(),
|
|
69
|
+
marketplacePlanId: 0,
|
|
70
|
+
sender,
|
|
71
|
+
};
|
|
72
|
+
await kv.put(planKey(orgLogin), JSON.stringify(subscription));
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Deletes the subscription record when an org cancels.
|
|
76
|
+
* On the next PR event they will fall back to free plan defaults.
|
|
77
|
+
* Calling this on a non-existent key is a no-op — safe.
|
|
78
|
+
*/
|
|
79
|
+
export async function deleteSubscription(orgLogin, kv) {
|
|
80
|
+
await kv.delete(planKey(orgLogin));
|
|
81
|
+
}
|
|
82
|
+
// ── Reverse index: Polar customer email → activated orgs ──────────────────────
|
|
83
|
+
//
|
|
84
|
+
// The activation flow lets a buyer PICK the org after paying, so the Polar
|
|
85
|
+
// subscription may not carry the org name. To revoke or change a plan when a
|
|
86
|
+
// cancellation/downgrade webhook arrives, we look the org(s) up by the customer
|
|
87
|
+
// email — which we always have on both sides.
|
|
88
|
+
const INDEX_PREFIX = 'actidx:';
|
|
89
|
+
function indexKey(email) {
|
|
90
|
+
return `${INDEX_PREFIX}${email.toLowerCase()}`;
|
|
91
|
+
}
|
|
92
|
+
/** Records that `email` activated `orgLogin` (idempotent). */
|
|
93
|
+
export async function recordActivation(email, orgLogin, kv) {
|
|
94
|
+
if (!email)
|
|
95
|
+
return;
|
|
96
|
+
const raw = await kv.get(indexKey(email));
|
|
97
|
+
const orgs = raw ? JSON.parse(raw) : [];
|
|
98
|
+
const org = orgLogin.toLowerCase();
|
|
99
|
+
if (!orgs.includes(org)) {
|
|
100
|
+
orgs.push(org);
|
|
101
|
+
await kv.put(indexKey(email), JSON.stringify(orgs));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Removes one org from an email's activation index. */
|
|
105
|
+
export async function removeActivation(email, orgLogin, kv) {
|
|
106
|
+
if (!email)
|
|
107
|
+
return;
|
|
108
|
+
const raw = await kv.get(indexKey(email));
|
|
109
|
+
if (!raw)
|
|
110
|
+
return;
|
|
111
|
+
const orgs = JSON.parse(raw).filter(o => o !== orgLogin.toLowerCase());
|
|
112
|
+
if (orgs.length === 0)
|
|
113
|
+
await kv.delete(indexKey(email));
|
|
114
|
+
else
|
|
115
|
+
await kv.put(indexKey(email), JSON.stringify(orgs));
|
|
116
|
+
}
|
|
117
|
+
/** All orgs a given customer email has activated. */
|
|
118
|
+
export async function findOrgsByEmail(email, kv) {
|
|
119
|
+
if (!email)
|
|
120
|
+
return [];
|
|
121
|
+
const raw = await kv.get(indexKey(email));
|
|
122
|
+
return raw ? JSON.parse(raw) : [];
|
|
123
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rules are UN-GATED: every tier — including Free (Developer) — runs the
|
|
3
|
+
* complete ruleset. The paid Studio tier is differentiated by unlimited private
|
|
4
|
+
* repositories, unlimited organizations, and support — never by hiding rules.
|
|
5
|
+
* (You can't prove value if the free product only catches a handful of issues.)
|
|
6
|
+
*
|
|
7
|
+
* Kept as a pass-through so callers don't need to change if gating ever returns.
|
|
8
|
+
*/
|
|
9
|
+
export function filterRulesForPlan(rules, _plan) {
|
|
10
|
+
return rules;
|
|
11
|
+
}
|
|
12
|
+
export function isPaidPlan(plan) {
|
|
13
|
+
return plan === 'studio';
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { isSupportedFile } from '../parsers/index.js';
|
|
4
|
+
import { isPathExcluded } from '../config/seocode-config.js';
|
|
5
|
+
// Directories a local scan should never descend into.
|
|
6
|
+
const SKIP_DIRS = new Set([
|
|
7
|
+
'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
|
|
8
|
+
'.cache', '.turbo', '.vercel', '.svelte-kit', '.astro', '.output',
|
|
9
|
+
]);
|
|
10
|
+
/**
|
|
11
|
+
* Walks the given paths (files or directories, relative to `root`) and returns
|
|
12
|
+
* the absolute paths of every SEOCode-reviewable file, minus build/vendor dirs
|
|
13
|
+
* and anything matched by the repo's `.seocode.json` exclude globs. Deterministic
|
|
14
|
+
* order so output is stable across runs.
|
|
15
|
+
*/
|
|
16
|
+
export function discoverFiles(root, inputs, excludeGlobs) {
|
|
17
|
+
const targets = inputs.length ? inputs.map(p => path.resolve(root, p)) : [root];
|
|
18
|
+
const found = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
const maybeAdd = (full) => {
|
|
21
|
+
if (seen.has(full) || !isSupportedFile(full))
|
|
22
|
+
return;
|
|
23
|
+
const rel = path.relative(root, full).split(path.sep).join('/');
|
|
24
|
+
if (isPathExcluded(rel, excludeGlobs))
|
|
25
|
+
return;
|
|
26
|
+
seen.add(full);
|
|
27
|
+
found.push(full);
|
|
28
|
+
};
|
|
29
|
+
const walk = (dir) => {
|
|
30
|
+
let entries;
|
|
31
|
+
try {
|
|
32
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
const full = path.join(dir, e.name);
|
|
39
|
+
if (e.isDirectory()) {
|
|
40
|
+
if (!SKIP_DIRS.has(e.name))
|
|
41
|
+
walk(full);
|
|
42
|
+
}
|
|
43
|
+
else if (e.isFile()) {
|
|
44
|
+
maybeAdd(full);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
for (const target of targets) {
|
|
49
|
+
let stat;
|
|
50
|
+
try {
|
|
51
|
+
stat = fs.statSync(target);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue; // path doesn't exist — skip quietly
|
|
55
|
+
}
|
|
56
|
+
if (stat.isFile())
|
|
57
|
+
maybeAdd(target);
|
|
58
|
+
else if (stat.isDirectory())
|
|
59
|
+
walk(target);
|
|
60
|
+
}
|
|
61
|
+
return found.sort();
|
|
62
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Applies every provably-correct 1-click fix (issues carrying a `suggestion`) to
|
|
3
|
+
* a file's content. Suggestions replace an inclusive 1-based line span with new
|
|
4
|
+
* text; we apply them bottom-up so earlier edits don't shift later line numbers.
|
|
5
|
+
* Only the safe, deterministic transforms the engine emits (e.g. `loading="lazy"`,
|
|
6
|
+
* `rel="noopener noreferrer"`) ever carry a suggestion — this never guesses.
|
|
7
|
+
*/
|
|
8
|
+
export function applyFixes(content, issues) {
|
|
9
|
+
const suggestions = issues
|
|
10
|
+
.map(i => i.suggestion)
|
|
11
|
+
.filter((s) => !!s)
|
|
12
|
+
.sort((a, b) => b.startLine - a.startLine); // bottom-up
|
|
13
|
+
if (suggestions.length === 0)
|
|
14
|
+
return { content, applied: 0 };
|
|
15
|
+
const lines = content.split('\n');
|
|
16
|
+
let applied = 0;
|
|
17
|
+
for (const s of suggestions) {
|
|
18
|
+
if (s.startLine < 1 || s.endLine < s.startLine || s.endLine > lines.length)
|
|
19
|
+
continue;
|
|
20
|
+
lines.splice(s.startLine - 1, s.endLine - s.startLine + 1, ...s.replacement.split('\n'));
|
|
21
|
+
applied++;
|
|
22
|
+
}
|
|
23
|
+
return { content: lines.join('\n'), applied };
|
|
24
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
const SEV_ORDER = ['critical', 'warning', 'info'];
|
|
3
|
+
/** Minimal ANSI — no dependency. Disabled when `color` is false. */
|
|
4
|
+
function painter(color) {
|
|
5
|
+
const wrap = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
6
|
+
return {
|
|
7
|
+
bold: wrap('1'), dim: wrap('2'),
|
|
8
|
+
red: wrap('31'), yellow: wrap('33'), blue: wrap('34'),
|
|
9
|
+
green: wrap('32'), gray: wrap('90'), cyan: wrap('36'),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const SEV_LABEL = { critical: 'error', warning: 'warn', info: 'info' };
|
|
13
|
+
/**
|
|
14
|
+
* ESLint-style terminal output: findings grouped by file, `line:col`-aligned,
|
|
15
|
+
* severity-colored, with a one-line summary. Returns the string to print; the
|
|
16
|
+
* caller decides the exit code from the returned counts.
|
|
17
|
+
*/
|
|
18
|
+
export function formatTerminal(report, opts) {
|
|
19
|
+
const c = painter(opts.color);
|
|
20
|
+
const sevColor = {
|
|
21
|
+
critical: c.red, warning: c.yellow, info: c.blue,
|
|
22
|
+
};
|
|
23
|
+
const out = [];
|
|
24
|
+
if (report.files.length === 0) {
|
|
25
|
+
out.push('');
|
|
26
|
+
out.push(` ${c.green('✔')} No SEO issues found ${c.dim(`(${report.scanned} file${report.scanned === 1 ? '' : 's'} scanned)`)}`);
|
|
27
|
+
out.push('');
|
|
28
|
+
return out.join('\n');
|
|
29
|
+
}
|
|
30
|
+
for (const fileReport of report.files) {
|
|
31
|
+
const relPath = path.relative(opts.root, fileReport.file).split(path.sep).join('/');
|
|
32
|
+
// A path outside cwd renders as ../../… — fall back to the given path instead.
|
|
33
|
+
const rel = (!relPath || relPath.startsWith('..')) ? fileReport.file : relPath;
|
|
34
|
+
out.push('');
|
|
35
|
+
out.push(c.bold(c.cyan(rel)));
|
|
36
|
+
// Sort issues: criticals first, then by line.
|
|
37
|
+
const issues = [...fileReport.issues].sort((a, b) => {
|
|
38
|
+
const sd = SEV_ORDER.indexOf(a.severity) - SEV_ORDER.indexOf(b.severity);
|
|
39
|
+
return sd !== 0 ? sd : (a.line ?? 0) - (b.line ?? 0);
|
|
40
|
+
});
|
|
41
|
+
const loc = (i) => (i.line != null ? `${i.line}` : '–').padStart(4);
|
|
42
|
+
for (const i of issues) {
|
|
43
|
+
const sev = i.severity;
|
|
44
|
+
const badge = sevColor[sev](SEV_LABEL[sev].padEnd(5));
|
|
45
|
+
out.push(` ${c.dim(loc(i))} ${badge} ${i.ruleName} ${c.dim(i.ruleId)}`);
|
|
46
|
+
out.push(` ${c.dim('→ ' + i.fix)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// ── Summary ──
|
|
50
|
+
out.push('');
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (report.critical)
|
|
53
|
+
parts.push(c.red(`${report.critical} error${report.critical === 1 ? '' : 's'}`));
|
|
54
|
+
if (report.warning)
|
|
55
|
+
parts.push(c.yellow(`${report.warning} warning${report.warning === 1 ? '' : 's'}`));
|
|
56
|
+
if (report.info)
|
|
57
|
+
parts.push(c.blue(`${report.info} info`));
|
|
58
|
+
const total = report.critical + report.warning + report.info;
|
|
59
|
+
const mark = report.critical > 0 ? c.red('✖') : c.yellow('▲');
|
|
60
|
+
out.push(` ${mark} ${total} problem${total === 1 ? '' : 's'} (${parts.join(', ')}) ${c.dim(`· ${report.scanned} files scanned`)}`);
|
|
61
|
+
if (report.critical > 0) {
|
|
62
|
+
out.push(` ${c.red('✖')} ${c.bold('deploy-blocking issues present')} — this would fail the merge check.`);
|
|
63
|
+
}
|
|
64
|
+
out.push('');
|
|
65
|
+
return out.join('\n');
|
|
66
|
+
}
|