@elia97/officina 0.10.0 → 0.12.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 (53) hide show
  1. package/README.md +88 -12
  2. package/dist/bin.js +5 -1
  3. package/dist/checks/bundle.js +15 -5
  4. package/dist/checks/doctor.js +32 -16
  5. package/dist/checks/lighthouse.js +4 -1
  6. package/dist/checks/links.d.ts +2 -0
  7. package/dist/checks/links.js +69 -0
  8. package/dist/checks/migrate.d.ts +2 -0
  9. package/dist/checks/migrate.js +44 -0
  10. package/dist/checks/placeholders.js +15 -6
  11. package/dist/checks/secrets.d.ts +2 -0
  12. package/dist/checks/secrets.js +46 -0
  13. package/dist/checks/smoke.js +23 -6
  14. package/dist/index.d.ts +1 -0
  15. package/dist/lib/alignment.d.ts +1 -0
  16. package/dist/lib/alignment.js +8 -2
  17. package/dist/lib/bundle-ssr.d.ts +2 -0
  18. package/dist/lib/bundle-ssr.js +1 -1
  19. package/dist/lib/check-placeholders.d.ts +3 -1
  20. package/dist/lib/check-placeholders.js +9 -3
  21. package/dist/lib/check-routes.js +5 -3
  22. package/dist/lib/cli.d.ts +1 -0
  23. package/dist/lib/cli.js +1 -0
  24. package/dist/lib/config-gaps.d.ts +5 -5
  25. package/dist/lib/config-gaps.js +9 -8
  26. package/dist/lib/config.d.ts +31 -3
  27. package/dist/lib/config.js +30 -4
  28. package/dist/lib/database-auth.d.ts +5 -0
  29. package/dist/lib/database-auth.js +81 -0
  30. package/dist/lib/env-schema.d.ts +8 -0
  31. package/dist/lib/env-schema.js +37 -0
  32. package/dist/lib/links.d.ts +30 -0
  33. package/dist/lib/links.js +101 -0
  34. package/dist/lib/migrate.d.ts +11 -0
  35. package/dist/lib/migrate.js +29 -0
  36. package/dist/lib/routes.d.ts +19 -7
  37. package/dist/lib/routes.js +39 -13
  38. package/dist/lib/secrets.d.ts +10 -0
  39. package/dist/lib/secrets.js +53 -0
  40. package/dist/lib/smoke-on-demand.d.ts +8 -0
  41. package/dist/lib/smoke-on-demand.js +65 -0
  42. package/dist/lib/smoke-production.d.ts +4 -1
  43. package/dist/lib/smoke-production.js +2 -0
  44. package/dist/lib/validate.d.ts +2 -0
  45. package/dist/lib/validate.js +9 -1
  46. package/dist/lib/vercel-build.d.ts +1 -0
  47. package/dist/lib/vercel-build.js +26 -4
  48. package/dist/lib/workflows.d.ts +14 -0
  49. package/dist/lib/workflows.js +44 -3
  50. package/dist/smoke.d.ts +1 -0
  51. package/dist/smoke.js +1 -0
  52. package/package.json +1 -1
  53. package/presets/biome.base.json +2 -1
@@ -0,0 +1,101 @@
1
+ import { listed } from './routes.js';
2
+ const VALUE = String.raw `(?:"[^"]*"|'[^']*'|[^\s"'>]+)`;
3
+ const ATTRIBUTES = String.raw `(?:\s+[^\s"'>/=]+(?:\s*=\s*${VALUE})?)*`;
4
+ // Astro conserva i commenti HTML, e nei valori dinamici degli attributi scappa solo `&` e `"`.
5
+ const MARKUP = new RegExp(String.raw `<!--[\s\S]*?-->|<(script|style)\b${ATTRIBUTES}\s*\/?>[\s\S]*?<\/\1\s*>|<a(${ATTRIBUTES})\s*\/?>|<[a-z][^\s/>]*${ATTRIBUTES}\s*\/?>`, 'gi');
6
+ const ATTRIBUTE = /\s+([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
7
+ const ENTITY = /&(?:#(\d{1,6})|#x([\da-f]{1,5})|(amp|lt|gt|quot|apos));/gi;
8
+ const NAMED = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
9
+ const SCHEME = /^[a-z][a-z\d+.-]*:/i;
10
+ const WEB = /^https?:/i;
11
+ const NO_SITE = 'https://officina.invalid';
12
+ const decodeEntities = (value) => value.replace(ENTITY, (_, decimal, hex, name) => {
13
+ if (decimal !== undefined)
14
+ return String.fromCodePoint(Number(decimal));
15
+ if (hex !== undefined)
16
+ return String.fromCodePoint(Number.parseInt(hex, 16));
17
+ return NAMED[name.toLowerCase()];
18
+ });
19
+ function hrefOf(attributes) {
20
+ for (const [, name, double, single, bare] of attributes.matchAll(ATTRIBUTE)) {
21
+ if (name.toLowerCase() === 'href')
22
+ return decodeEntities(double ?? single ?? bare ?? '');
23
+ }
24
+ return undefined;
25
+ }
26
+ export function htmlLinks(html) {
27
+ return [...html.matchAll(MARKUP)]
28
+ .map(([, , attributes]) => (attributes === undefined ? undefined : hrefOf(attributes)))
29
+ .filter((href) => href !== undefined);
30
+ }
31
+ export const siteOrigin = (siteUrl) => siteUrl !== undefined && URL.canParse(siteUrl) ? new URL(siteUrl).origin : NO_SITE;
32
+ function decoded(path) {
33
+ try {
34
+ return decodeURIComponent(path);
35
+ }
36
+ catch {
37
+ return path;
38
+ }
39
+ }
40
+ export function linkTarget(href, page) {
41
+ const value = href.trim();
42
+ if (value === '' || value === '#')
43
+ return { kind: 'placeholder' };
44
+ if (value.startsWith('#'))
45
+ return { kind: 'anchor' };
46
+ if (SCHEME.test(value) && !WEB.test(value))
47
+ return { kind: 'scheme' };
48
+ if (!URL.canParse(value, page))
49
+ return { kind: 'internal', path: value };
50
+ const url = new URL(value, page);
51
+ return url.origin === page.origin ? { kind: 'internal', path: decoded(url.pathname) } : { kind: 'external' };
52
+ }
53
+ export function servedPaths(files) {
54
+ const served = new Set();
55
+ for (const file of files) {
56
+ served.add(file);
57
+ if (file.endsWith('/index.html'))
58
+ served.add(file.slice(0, -'/index.html'.length) || '/');
59
+ else if (file.endsWith('.html'))
60
+ served.add(file.slice(0, -'.html'.length));
61
+ }
62
+ return served;
63
+ }
64
+ const withoutTrailingSlash = (path) => (path.length > 1 ? path.replace(/\/+$/, '') : path);
65
+ export function servedBy(path, served, onDemand) {
66
+ const bare = withoutTrailingSlash(path);
67
+ if (served.has(path) || served.has(bare))
68
+ return 'build';
69
+ return onDemand.some((pattern) => pattern.test(bare)) ? 'on-demand' : undefined;
70
+ }
71
+ const placeholderLabel = (href) => `segnaposto href="${href.trim()}"`;
72
+ export function linkReport(pages, origin, served, onDemand) {
73
+ const citing = new Map();
74
+ const report = { broken: [], internal: 0, onDemand: 0, outside: { external: 0, scheme: 0, anchor: 0 } };
75
+ const cite = (destination, route) => citing.set(destination, (citing.get(destination) ?? new Set()).add(route));
76
+ const checkInternal = (path, route) => {
77
+ report.internal++;
78
+ const by = servedBy(path, served, onDemand);
79
+ if (by === undefined)
80
+ cite(withoutTrailingSlash(path), route);
81
+ if (by === 'on-demand')
82
+ report.onDemand++;
83
+ };
84
+ for (const { route, hrefs } of pages) {
85
+ const page = new URL(route, origin);
86
+ for (const href of hrefs) {
87
+ const target = linkTarget(href, page);
88
+ if (target.kind === 'placeholder')
89
+ cite(placeholderLabel(href), route);
90
+ else if (target.kind === 'internal')
91
+ checkInternal(target.path, route);
92
+ else
93
+ report.outside[target.kind]++;
94
+ }
95
+ }
96
+ report.broken = [...citing]
97
+ .sort(([a], [b]) => (a < b ? -1 : 1))
98
+ .map(([destination, routes]) => ({ destination, pages: [...routes].sort() }));
99
+ return report;
100
+ }
101
+ export const brokenLines = ({ broken }) => broken.map(({ destination, pages }) => `${destination} ← ${listed(pages)}`);
@@ -0,0 +1,11 @@
1
+ type Env = Readonly<Record<string, string | undefined>>;
2
+ export type MigrationEnv = {
3
+ env: Record<string, string | undefined>;
4
+ url: string;
5
+ } | {
6
+ error: string;
7
+ };
8
+ export type MigrationTarget = 'test' | 'production';
9
+ export declare function migrationEnv(env: Env, key: string, target: MigrationTarget): MigrationEnv;
10
+ export declare function maskedValues(url: string): string[];
11
+ export {};
@@ -0,0 +1,29 @@
1
+ const SOURCES = {
2
+ test: {
3
+ variable: 'TEST_DATABASE_URL',
4
+ origin: "è l'indirizzo del branch di test, che `actions/ci` riceve con `database-url`",
5
+ },
6
+ production: {
7
+ variable: 'PRODUCTION_DATABASE_URL',
8
+ origin: "è l'indirizzo di produzione, che `actions/deploy` riceve con `database-url` dal segreto `PRODUCTION_DATABASE_URL` dell'environment `production`, dichiarato dal job di deploy",
9
+ },
10
+ };
11
+ export function migrationEnv(env, key, target) {
12
+ const { variable, origin } = SOURCES[target];
13
+ const { [variable]: url, ...rest } = env;
14
+ if (!url)
15
+ return { error: `\`${variable}\` è vuota: ${origin}` };
16
+ return { env: { ...rest, [key]: url }, url };
17
+ }
18
+ const decoded = (text) => {
19
+ try {
20
+ return decodeURIComponent(text);
21
+ }
22
+ catch {
23
+ return text;
24
+ }
25
+ };
26
+ export function maskedValues(url) {
27
+ const password = URL.parse(url)?.password ?? '';
28
+ return [...new Set(password === '' ? [url] : [url, password, decoded(password)])];
29
+ }
@@ -2,16 +2,18 @@ export type PageFile = {
2
2
  file: string;
3
3
  source: string;
4
4
  };
5
+ type DynamicRoute = {
6
+ pattern: RegExp;
7
+ label: string;
8
+ file: string;
9
+ };
5
10
  export type Expectations = {
6
11
  exact: {
7
12
  route: string;
8
13
  file: string;
9
14
  }[];
10
- patterns: {
11
- pattern: RegExp;
12
- label: string;
13
- file: string;
14
- }[];
15
+ patterns: DynamicRoute[];
16
+ disabled: DynamicRoute[];
15
17
  ssr: string[];
16
18
  };
17
19
  export type VerifiedRoute = {
@@ -25,11 +27,21 @@ export declare const NON_HTML_ROUTES: readonly VerifiedRoute[];
25
27
  export declare function filesWithExtension(dir: string, extension: string): string[];
26
28
  export declare function readPageFiles(pagesDir: string): PageFile[];
27
29
  export declare function routeOf(htmlPath: string, dist: string): string;
28
- export declare function expectedRoutes(pages: readonly PageFile[], pagesDir: string): Expectations;
30
+ export declare function pageRouteOf(file: string, pagesDir: string): string;
31
+ export declare function routePattern(route: string): RegExp;
32
+ export declare function expectedRoutes(pages: readonly PageFile[], pagesDir: string, disabled?: readonly string[]): Expectations;
33
+ export type EmittedRoutes = {
34
+ html: readonly string[];
35
+ ssr: readonly string[];
36
+ };
29
37
  /** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
30
- export declare function missingRouteFailures(expected: Expectations, emitted: readonly string[], dist: string): string[];
38
+ export declare function missingRouteFailures(expected: Expectations, emitted: EmittedRoutes, dist: string): string[];
39
+ export declare const listed: (routes: readonly string[]) => string;
40
+ export declare function disabledRouteFailures(expected: Expectations, { html, ssr }: EmittedRoutes): string[];
41
+ export declare function unknownDisabled(expected: Expectations, disabled?: readonly string[]): string[];
31
42
  /** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
32
43
  export declare function missingRepresentatives(expected: Expectations, representatives?: Representatives): string[];
33
44
  export declare function auditRoutes(expected: Expectations, representatives?: Representatives): string[];
34
45
  export declare function smokeRoutes(expected: Expectations, nonHtmlRoutes?: readonly VerifiedRoute[], representatives?: Representatives): VerifiedRoute[];
35
46
  export declare function orphanExceptions(expected: Expectations): string[];
47
+ export {};
@@ -28,27 +28,29 @@ export function routeOf(htmlPath, dist) {
28
28
  return route.replace(/\.html$/, '') || '/';
29
29
  }
30
30
  const SSR_OPT_OUT = /^\s*export\s+const\s+prerender\s*=\s*false\b/m;
31
- function pageRouteOf(file, pagesDir) {
31
+ export function pageRouteOf(file, pagesDir) {
32
32
  return (toPosix(file, pagesDir)
33
33
  .replace(/\.astro$/, '')
34
34
  .replace(/\/index$/, '') || '/');
35
35
  }
36
+ const PARAMETER = /(\[[^\]]*\])/;
36
37
  function segmentPattern(segment) {
37
- if (segment.startsWith('['))
38
- return '[^/]+';
39
- return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ return segment
39
+ .split(PARAMETER)
40
+ .map((part) => (part.startsWith('[') ? '[^/]+' : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
41
+ .join('');
40
42
  }
41
43
  const TRAILING_REST_SEGMENT = /\/\[\.\.\.[^/]*\]$/;
42
44
  // Anche un segmento rest finale fa match col vuoto: `paginate()` emette la prima pagina come
43
45
  // percorso nudo (`/news`, mai `/news/1`), quindi esigere un segmento boccia un archivio di una pagina.
44
- function routePattern(route) {
46
+ export function routePattern(route) {
45
47
  const body = (path) => path.split('/').map(segmentPattern).join('/');
46
48
  if (!TRAILING_REST_SEGMENT.test(route))
47
49
  return new RegExp(`^${body(route)}$`);
48
50
  return new RegExp(`^${body(route.replace(TRAILING_REST_SEGMENT, ''))}(?:/.*)?$`);
49
51
  }
50
- export function expectedRoutes(pages, pagesDir) {
51
- const expectations = { exact: [], patterns: [], ssr: [] };
52
+ export function expectedRoutes(pages, pagesDir, disabled = []) {
53
+ const expectations = { exact: [], patterns: [], disabled: [], ssr: [] };
52
54
  for (const { file, source } of pages) {
53
55
  if (SSR_OPT_OUT.test(source)) {
54
56
  expectations.ssr.push(file);
@@ -59,28 +61,52 @@ export function expectedRoutes(pages, pagesDir) {
59
61
  expectations.exact.push({ route, file });
60
62
  continue;
61
63
  }
62
- expectations.patterns.push({ pattern: routePattern(route), label: route, file });
64
+ const target = disabled.includes(route) ? expectations.disabled : expectations.patterns;
65
+ target.push({ pattern: routePattern(route), label: route, file });
63
66
  }
64
67
  return expectations;
65
68
  }
69
+ // Il manifest di Astro nomina una rotta resa a richiesta col suo pattern (`/blog/[slug]`), non coi percorsi che serve.
70
+ const emits = ({ pattern, label }, { html, ssr }) => html.some((route) => pattern.test(route)) || ssr.includes(label);
66
71
  /** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
67
72
  export function missingRouteFailures(expected, emitted, dist) {
68
- if (emitted.length === 0) {
73
+ if (emitted.html.length + emitted.ssr.length === 0) {
69
74
  return [`${dist} non contiene nessun .html: nessuna rotta misurata, quindi i budget per rotta non affermano niente`];
70
75
  }
71
76
  const failures = [];
72
- const routes = new Set(emitted);
77
+ const routes = new Set([...emitted.html, ...emitted.ssr]);
73
78
  for (const { route, file } of expected.exact) {
74
79
  if (!routes.has(route))
75
80
  failures.push(`rotta mancante ${route}: ${file} è prerenderizzata ma non ha emesso HTML`);
76
81
  }
77
- for (const { pattern, label, file } of expected.patterns) {
78
- if (emitted.some((route) => pattern.test(route)))
82
+ for (const page of expected.patterns) {
83
+ if (emits(page, emitted))
79
84
  continue;
80
- failures.push(`rotta mancante ${label}: ${file} è prerenderizzata ma getStaticPaths non ha emesso niente`);
85
+ failures.push(`rotta mancante ${page.label}: ${page.file} è prerenderizzata ma getStaticPaths non ha emesso niente`);
81
86
  }
82
87
  return failures;
83
88
  }
89
+ const SHOWN = 3;
90
+ export const listed = (routes) => routes.length <= SHOWN ? routes.join(', ') : `${routes.slice(0, SHOWN).join(', ')} e altre ${routes.length - SHOWN}`;
91
+ export function disabledRouteFailures(expected, { html, ssr }) {
92
+ const exact = new Set(expected.exact.map(({ route }) => route));
93
+ const unexplained = html.filter((route) => !exact.has(route) && !expected.patterns.some(({ pattern }) => pattern.test(route)));
94
+ return expected.disabled.flatMap(({ pattern, label }) => {
95
+ if (ssr.includes(label)) {
96
+ return [
97
+ `rotta spenta ${label}: è in \`routes.disabled\`, ma la build la rende a richiesta, e si spegne solo una pagina prerenderizzata`,
98
+ ];
99
+ }
100
+ const routes = unexplained.filter((route) => pattern.test(route)).sort();
101
+ if (routes.length === 0)
102
+ return [];
103
+ return [`rotta spenta ${label}: è in \`routes.disabled\`, ma la build ha emesso ${listed(routes)}`];
104
+ });
105
+ }
106
+ export function unknownDisabled(expected, disabled = []) {
107
+ const known = new Set(expected.disabled.map(({ label }) => label));
108
+ return disabled.filter((label) => !known.has(label));
109
+ }
84
110
  /** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
85
111
  export function missingRepresentatives(expected, representatives = {}) {
86
112
  return expected.patterns
@@ -0,0 +1,10 @@
1
+ import type { Finding } from './cli.ts';
2
+ import type { EnvField } from './env-schema.ts';
3
+ export declare function secretFields(fields: readonly EnvField[]): EnvField[];
4
+ export declare const canary: (key: string) => string;
5
+ export declare const canaryLines: (secrets: readonly EnvField[]) => string[];
6
+ export interface SecretScan {
7
+ findings: Finding[];
8
+ verified: string[];
9
+ }
10
+ export declare function scanSecrets(keys: readonly string[], env: Readonly<Record<string, string | undefined>>, files: readonly string[], read: (path: string) => Buffer): SecretScan;
@@ -0,0 +1,53 @@
1
+ const SCHEMA = 'astro.config.mjs';
2
+ const MIN_SEARCHABLE = 8;
3
+ export function secretFields(fields) {
4
+ const secrets = fields.filter(({ context, access }) => context === 'server' && access === 'secret');
5
+ return secrets.filter(({ key }, index) => secrets.findIndex((field) => field.key === key) === index);
6
+ }
7
+ export const canary = (key) => `officina-${key}-canary`;
8
+ // Astro valida ogni variabile sul tipo e sulle validazioni del suo envField: un canary di testo non
9
+ // è un `number`, un `boolean` né un `enum`, e non rispetta `url`, `startsWith` e simili.
10
+ export const canaryLines = (secrets) => secrets.filter(({ type, constrained }) => type === 'string' && !constrained).map(({ key }) => `${key}=${canary(key)}`);
11
+ // Le due forme in cui il runtime di Astro scrive un valore: escapeHTML nel markup, stringifyForScript
12
+ // negli script (astro/dist/runtime/server/escape.js).
13
+ const written = (value) => [
14
+ value,
15
+ value
16
+ .replaceAll('&', '&amp;')
17
+ .replaceAll('<', '&lt;')
18
+ .replaceAll('>', '&gt;')
19
+ .replaceAll("'", '&#39;')
20
+ .replaceAll('"', '&quot;'),
21
+ JSON.stringify(value).slice(1, -1).replaceAll('<', '\\u003c'),
22
+ ];
23
+ function target(key, raw) {
24
+ const value = raw?.trim() ?? '';
25
+ const own = value.length >= MIN_SEARCHABLE && value !== canary(key) ? written(value) : [];
26
+ return { key, value, needles: [...new Set([canary(key), ...own])] };
27
+ }
28
+ const searchable = ({ value }) => value.length >= MIN_SEARCHABLE;
29
+ const leaksIn = (path, content, targets) => targets
30
+ .filter(({ needles }) => needles.some((needle) => content.includes(needle)))
31
+ .map(({ key }) => ({ path, key, viaCanary: content.includes(canary(key)) }));
32
+ const leakFinding = ({ path, key, viaCanary }) => ({
33
+ path,
34
+ severity: 'error',
35
+ message: `contiene ${viaCanary ? 'il canary' : 'il valore'} di \`${key}\`, una chiave server e secret di env.schema`,
36
+ });
37
+ const unverifiable = ({ key, value }) => ({
38
+ path: SCHEMA,
39
+ severity: 'warning',
40
+ message: value === ''
41
+ ? `\`${key}\` non si verifica: nell'ambiente non ci sono né il suo valore né il canary`
42
+ : `\`${key}\` non si verifica: il suo valore ha meno di ${MIN_SEARCHABLE} caratteri, e cercarlo darebbe falsi positivi`,
43
+ });
44
+ export function scanSecrets(keys, env, files, read) {
45
+ const targets = keys.map((key) => target(key, env[key]));
46
+ const leaks = files.flatMap((path) => leaksIn(path, read(path), targets));
47
+ const leaked = new Set(leaks.map(({ key }) => key));
48
+ const clean = targets.filter(({ key }) => !leaked.has(key));
49
+ return {
50
+ findings: [...leaks.map(leakFinding), ...clean.filter((t) => !searchable(t)).map(unverifiable)],
51
+ verified: clean.filter(searchable).map(({ key }) => key),
52
+ };
53
+ }
@@ -0,0 +1,8 @@
1
+ import { type Expectations, type Representatives } from './routes.ts';
2
+ import type { CheckResult, SmokeContext } from './smoke-production.ts';
3
+ export type OnDemandPage = {
4
+ label: string;
5
+ path?: string;
6
+ };
7
+ export declare function onDemandPages(expected: Expectations, pagesDir: string, representatives?: Representatives): OnDemandPage[];
8
+ export declare function checkOnDemandPages(context: SmokeContext): Promise<CheckResult[]>;
@@ -0,0 +1,65 @@
1
+ import { pageRouteOf } from './routes.js';
2
+ export function onDemandPages(expected, pagesDir, representatives = {}) {
3
+ return expected.ssr
4
+ .map((file) => pageRouteOf(file, pagesDir))
5
+ .sort()
6
+ .map((label) => {
7
+ const path = label.includes('[') ? representatives[label] : label;
8
+ return path === undefined ? { label } : { label, path };
9
+ });
10
+ }
11
+ const META = /<meta\b[^>]*>/gi;
12
+ const HTTP_EQUIV = /(?<![-\w])http-equiv\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
13
+ const CONTENT = /(?<![-\w])content\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/i;
14
+ const SCRIPT_SRC = /(?:^|;)\s*script-src(?:\s|;|$)/i;
15
+ const attributeValue = (tag, attribute) => {
16
+ const match = attribute.exec(tag);
17
+ return match?.[1] ?? match?.[2] ?? match?.[3] ?? '';
18
+ };
19
+ const hasCspMeta = (html) => [...html.matchAll(META)].some(([tag]) => attributeValue(tag, HTTP_EQUIV).toLowerCase() === 'content-security-policy' &&
20
+ SCRIPT_SRC.test(attributeValue(tag, CONTENT)));
21
+ // TODO: dalla 0.13.0 un'anomalia di una pagina resa a richiesta fa fallire lo smoke: qui `warn` diventa `fail`.
22
+ const warn = (check, detail) => ({ check, status: 'warn', detail });
23
+ function redirect(check, response, url) {
24
+ const location = response.headers.get('location');
25
+ if (location === null)
26
+ return warn(check, `risponde ${response.status} senza location`);
27
+ const target = new URL(location, url);
28
+ const { origin } = new URL(url);
29
+ if (target.origin !== origin)
30
+ return warn(check, `rimanda a ${target.href}, fuori da ${origin}`);
31
+ return { check, status: 'pass' };
32
+ }
33
+ async function visit({ get, baseUrl }, path) {
34
+ const check = `GET ${path} (a richiesta)`;
35
+ const url = `${baseUrl}${path}`;
36
+ try {
37
+ const response = await get(url);
38
+ if (response.status >= 300 && response.status < 400)
39
+ return redirect(check, response, url);
40
+ if (response.status !== 200) {
41
+ return warn(check, `atteso 200 o un redirect sulla stessa origine, ricevuto ${response.status}`);
42
+ }
43
+ const contentType = response.headers.get('content-type') ?? '';
44
+ if (!contentType.includes('text/html'))
45
+ return warn(check, `atteso un content-type text/html, ricevuto "${contentType}"`);
46
+ if (!hasCspMeta((await response.text?.()) ?? ''))
47
+ return warn(check, 'manca il meta Content-Security-Policy con script-src');
48
+ return { check, status: 'pass' };
49
+ }
50
+ catch (error) {
51
+ return warn(check, error instanceof Error ? error.message : String(error));
52
+ }
53
+ }
54
+ export async function checkOnDemandPages(context) {
55
+ const results = [];
56
+ for (const { label, path } of context.onDemand ?? []) {
57
+ if (path !== undefined)
58
+ results.push(await visit(context, path));
59
+ else {
60
+ const detail = 'pagina dinamica senza rappresentante in `routes.representatives`';
61
+ results.push({ check: `pagina ${label} (a richiesta)`, status: 'skip', detail });
62
+ }
63
+ }
64
+ return results;
65
+ }
@@ -1,10 +1,12 @@
1
1
  import type { VerifiedRoute } from './routes.ts';
2
+ import { type OnDemandPage } from './smoke-on-demand.ts';
2
3
  export interface SmokeResponse {
3
4
  ok: boolean;
4
5
  status: number;
5
6
  headers: {
6
7
  get: (name: string) => string | null;
7
8
  };
9
+ text?: () => Promise<string>;
8
10
  }
9
11
  export type Fetcher = (url: string) => Promise<SmokeResponse>;
10
12
  export interface SmokeContext {
@@ -13,11 +15,12 @@ export interface SmokeContext {
13
15
  siteUrl: string;
14
16
  securityHeaders: Record<string, string | null>;
15
17
  botIdRequired: boolean;
18
+ onDemand?: readonly OnDemandPage[];
16
19
  }
17
20
  export type SmokeCheck = (context: SmokeContext, pages: readonly VerifiedRoute[]) => Promise<CheckResult[]>;
18
21
  export interface CheckResult {
19
22
  check: string;
20
- status: 'pass' | 'fail' | 'skip';
23
+ status: 'pass' | 'fail' | 'skip' | 'warn';
21
24
  detail?: string;
22
25
  }
23
26
  export declare const SECURITY_HEADERS: Record<string, string | null>;
@@ -1,3 +1,4 @@
1
+ import { checkOnDemandPages } from './smoke-on-demand.js';
1
2
  const pass = (check) => ({ check, status: 'pass' });
2
3
  const fail = (check, detail) => ({ check, status: 'fail', detail });
3
4
  const skip = (check, detail) => ({ check, status: 'skip', detail });
@@ -144,6 +145,7 @@ export async function checkTrailingSlash({ get, baseUrl }, pages) {
144
145
  }
145
146
  export const DEFAULT_CHECKS = [
146
147
  checkPages,
148
+ checkOnDemandPages,
147
149
  checkSecurityHeaders,
148
150
  checkBotIdChallenge,
149
151
  checkCanonicalHost,
@@ -1,10 +1,12 @@
1
1
  export type Check = (value: unknown, path: string) => string[];
2
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
2
3
  export declare function typeName(value: unknown): string;
3
4
  export declare const problem: (path: string, expected: string, value: unknown) => string;
4
5
  export declare const aString: Check;
5
6
  export declare const aNumber: Check;
6
7
  export declare const aFunction: Check;
7
8
  export declare const aStringOrNull: Check;
9
+ export declare const anEnvName: Check;
8
10
  export declare const anArrayOf: (item: Check) => Check;
9
11
  export declare const aRecordOf: (item: Check) => Check;
10
12
  export declare const oneOf: (expected: string, allowed: readonly unknown[]) => Check;
@@ -1,4 +1,4 @@
1
- const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
1
+ export const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
2
2
  const NAMES = new Map([
3
3
  ['string', 'una stringa'],
4
4
  ['number', 'un numero'],
@@ -18,6 +18,14 @@ export const aString = (value, path) => (typeof value === 'string' ? [] : [probl
18
18
  export const aNumber = (value, path) => (typeof value === 'number' ? [] : [problem(path, 'un numero', value)]);
19
19
  export const aFunction = (value, path) => typeof value === 'function' ? [] : [problem(path, 'una funzione', value)];
20
20
  export const aStringOrNull = (value, path) => typeof value === 'string' || value === null ? [] : [problem(path, 'una stringa oppure null', value)];
21
+ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
22
+ export const anEnvName = (value, path) => {
23
+ if (typeof value !== 'string')
24
+ return [problem(path, 'una stringa', value)];
25
+ if (ENV_NAME.test(value))
26
+ return [];
27
+ return [`${path}: atteso il nome di una variabile d'ambiente, ricevuto ${JSON.stringify(value)}`];
28
+ };
21
29
  export const anArrayOf = (item) => (value, path) => Array.isArray(value)
22
30
  ? value.flatMap((entry, index) => item(entry, `${path}[${index}]`))
23
31
  : [problem(path, 'un array', value)];
@@ -10,3 +10,4 @@ export type SsrRoute = {
10
10
  unknownIslands: string[];
11
11
  };
12
12
  export declare function ssrRoutes(files: readonly ServerFile[]): SsrRoute[] | null;
13
+ export declare function onDemandPatterns(files: readonly ServerFile[]): RegExp[] | null;
@@ -1,4 +1,5 @@
1
1
  import { posix } from 'node:path';
2
+ import { routePattern } from './routes.js';
2
3
  const MANIFEST_CALL = 'deserializeManifest({';
3
4
  const ASSET = /^(?:.*\/)?_astro\/([^/?#]+\.js)(?:[?#].*)?$/;
4
5
  const STYLESHEET = /^(?:.*\/)?_astro\/([^?#]+\.css)(?:[?#].*)?$/;
@@ -102,14 +103,35 @@ function ssrPage({ scripts = [], styles, routeData }) {
102
103
  const { route, component } = routeData;
103
104
  return typeof route === 'string' && typeof component === 'string' ? [{ scripts, styles, route, component }] : [];
104
105
  }
105
- export function ssrRoutes(files) {
106
- for (const file of files) {
107
- const manifest = parseManifest(file.source);
106
+ function firstManifest(files) {
107
+ for (const host of files) {
108
+ const manifest = parseManifest(host.source);
108
109
  if (manifest !== null)
109
- return routesOf(file, manifest, files);
110
+ return { host, manifest };
110
111
  }
111
112
  return null;
112
113
  }
114
+ export function ssrRoutes(files) {
115
+ const found = firstManifest(files);
116
+ return found === null ? null : routesOf(found.host, found.manifest, files);
117
+ }
118
+ function astroPattern(route, pattern) {
119
+ if (typeof pattern === 'string') {
120
+ try {
121
+ return new RegExp(pattern);
122
+ }
123
+ catch { }
124
+ }
125
+ return routePattern(route);
126
+ }
127
+ export function onDemandPatterns(files) {
128
+ const found = firstManifest(files);
129
+ if (found === null)
130
+ return null;
131
+ return (found.manifest.routes ?? []).flatMap(({ routeData }) => routeData?.prerender === false && typeof routeData.route === 'string'
132
+ ? [astroPattern(routeData.route, routeData.pattern)]
133
+ : []);
134
+ }
113
135
  function routesOf(host, manifest, files) {
114
136
  const sources = new Map(files.map((file) => [file.path, file.source]));
115
137
  const chunks = pageChunks(host);
@@ -4,3 +4,17 @@ export declare const isLive: (line: string) => boolean;
4
4
  export declare const mentions: (source: string | undefined, needle: string) => boolean;
5
5
  /** Le action del progetto devono stare alla stessa versione del pacchetto installato. */
6
6
  export declare function workflowGaps({ read }: ProjectFiles, version: string): ContractGap[];
7
+ export interface ActionInputs {
8
+ workflow: string;
9
+ action: string;
10
+ inputs: readonly {
11
+ name: string;
12
+ reason: string;
13
+ notFrom?: readonly {
14
+ secret: string;
15
+ reason: string;
16
+ }[];
17
+ }[];
18
+ }
19
+ /** Un workflow che non usa l'action lo segnala già `workflowGaps`. */
20
+ export declare function actionInputGaps({ read }: ProjectFiles, { workflow, action, inputs }: ActionInputs): ContractGap[];
@@ -1,5 +1,5 @@
1
- // Niente parser YAML nel pacchetto: la ricerca è testuale. Una riga commentata non soddisfa nessun
2
- // controllo, altrimenti basta commentare un passo per avere un progetto «a posto» che non lo lancia.
1
+ import { parse } from 'yaml';
2
+ import { isRecord } from './validate.js';
3
3
  export const isLive = (line) => !line.trimStart().startsWith('#');
4
4
  const liveLines = (source) => source.split(/\r?\n/).filter(isLive);
5
5
  export const mentions = (source, needle) => source !== undefined && liveLines(source).some((line) => line.includes(needle));
@@ -36,8 +36,9 @@ function refGap(line, ref, name, expected) {
36
36
  ? undefined
37
37
  : `\`${name}@${ref.slice(0, 7)}… ${declared}\`: il pacchetto installato è la ${expected.slice(1)}`;
38
38
  }
39
+ const actionName = (action) => `Elia97/officina/actions/${action}`;
39
40
  function actionGap(source, action, version) {
40
- const name = `Elia97/officina/actions/${action}`;
41
+ const name = actionName(action);
41
42
  if (source === undefined)
42
43
  return `manca: i suoi passi arrivano da \`${name}\``;
43
44
  const found = refOf(source, name);
@@ -50,3 +51,43 @@ export function workflowGaps({ read }, version) {
50
51
  return message === undefined ? [] : [{ path: workflow, message }];
51
52
  });
52
53
  }
54
+ function parsed(source) {
55
+ try {
56
+ return { workflow: parse(source) };
57
+ }
58
+ catch (error) {
59
+ return { error: `non è YAML valido: ${String(error).replace(/\n[\s\S]*$/, '')}` };
60
+ }
61
+ }
62
+ function stepsUsing(source, action) {
63
+ const result = parsed(source);
64
+ if ('error' in result)
65
+ return result.error;
66
+ const { workflow } = result;
67
+ const jobs = isRecord(workflow) && isRecord(workflow.jobs) ? Object.values(workflow.jobs) : [];
68
+ return jobs
69
+ .flatMap((job) => (isRecord(job) && Array.isArray(job.steps) ? job.steps : []))
70
+ .filter((step) => isRecord(step) && String(step.uses).startsWith(`${actionName(action)}@`));
71
+ }
72
+ const inputValue = (step, input) => isRecord(step.with) ? step.with[input] : undefined;
73
+ const receives = (step, input) => {
74
+ const value = inputValue(step, input);
75
+ return value !== undefined && value !== null && value !== '';
76
+ };
77
+ const receivesFrom = (step, input, secret) => String(inputValue(step, input)).split(/\W/).includes(secret);
78
+ /** Un workflow che non usa l'action lo segnala già `workflowGaps`. */
79
+ export function actionInputGaps({ read }, { workflow, action, inputs }) {
80
+ const source = read(workflow);
81
+ if (source === undefined)
82
+ return [];
83
+ const steps = stepsUsing(source, action);
84
+ if (typeof steps === 'string')
85
+ return [{ path: workflow, message: steps }];
86
+ const gap = (message) => [{ path: workflow, message: `\`${actionName(action)}\` ${message}` }];
87
+ return inputs.flatMap(({ name, reason, notFrom = [] }) => {
88
+ if (steps.some((step) => !receives(step, name)))
89
+ return gap(`non riceve \`${name}\`: ${reason}`);
90
+ const wrong = notFrom.find(({ secret }) => steps.some((step) => receivesFrom(step, name, secret)));
91
+ return wrong === undefined ? [] : gap(`riceve \`${name}\` da \`${wrong.secret}\`: ${wrong.reason}`);
92
+ });
93
+ }
package/dist/smoke.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { NON_HTML_ROUTES } from './lib/routes.ts';
2
+ export { checkOnDemandPages } from './lib/smoke-on-demand.ts';
2
3
  export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.ts';
package/dist/smoke.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export { NON_HTML_ROUTES } from './lib/routes.js';
2
+ export { checkOnDemandPages } from './lib/smoke-on-demand.js';
2
3
  export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.js';