@elia97/officina 0.3.0 → 0.5.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 (59) hide show
  1. package/README.md +103 -10
  2. package/dist/bin.js +0 -0
  3. package/dist/bundle.d.ts +1 -0
  4. package/dist/bundle.js +1 -0
  5. package/dist/checks/analytics.js +14 -8
  6. package/dist/checks/bundle.js +18 -10
  7. package/dist/checks/doctor.d.ts +1 -1
  8. package/dist/checks/doctor.js +72 -8
  9. package/dist/checks/lighthouse.d.ts +1 -1
  10. package/dist/checks/lighthouse.js +9 -5
  11. package/dist/checks/roadmap.d.ts +1 -1
  12. package/dist/checks/roadmap.js +11 -5
  13. package/dist/checks/smoke.js +11 -10
  14. package/dist/checks/vercel-cli.js +33 -17
  15. package/dist/gen/hook-points.d.mts +6 -0
  16. package/dist/gen/hook-points.mjs +13 -0
  17. package/dist/gen/inject-section.d.mts +25 -2
  18. package/dist/gen/inject-section.mjs +13 -13
  19. package/dist/gen/inject-strings.d.mts +3 -0
  20. package/dist/gen/inject-strings.mjs +7 -3
  21. package/dist/gen/section-targets.d.mts +5 -0
  22. package/dist/gen/section-targets.mjs +23 -0
  23. package/dist/icons.d.ts +1 -0
  24. package/dist/icons.js +2 -0
  25. package/dist/index.d.ts +0 -4
  26. package/dist/index.js +0 -4
  27. package/dist/lib/alignment.d.ts +19 -0
  28. package/dist/lib/alignment.js +139 -0
  29. package/dist/lib/anchors.d.ts +2 -0
  30. package/dist/lib/anchors.js +35 -0
  31. package/dist/lib/bundle-budget.d.ts +6 -1
  32. package/dist/lib/bundle-budget.js +9 -6
  33. package/dist/lib/config-gaps.d.ts +7 -0
  34. package/dist/lib/config-gaps.js +29 -0
  35. package/dist/lib/config.d.ts +17 -1
  36. package/dist/lib/config.js +36 -3
  37. package/dist/lib/contract.d.ts +8 -0
  38. package/dist/lib/contract.js +51 -6
  39. package/dist/lib/pins.d.ts +10 -0
  40. package/dist/lib/pins.js +48 -0
  41. package/dist/lib/routes.d.ts +6 -2
  42. package/dist/lib/routes.js +19 -10
  43. package/dist/lib/smoke-production.js +17 -17
  44. package/dist/lib/tooling.d.ts +3 -0
  45. package/dist/lib/tooling.js +24 -0
  46. package/dist/lib/validate.d.ts +11 -0
  47. package/dist/lib/validate.js +40 -0
  48. package/dist/lib/versions.d.ts +5 -0
  49. package/dist/lib/versions.js +38 -0
  50. package/dist/lib/workflows.d.ts +6 -0
  51. package/dist/lib/workflows.js +70 -0
  52. package/dist/sh/lhci-local.sh +12 -12
  53. package/dist/smoke.d.ts +2 -0
  54. package/dist/smoke.js +4 -0
  55. package/package.json +31 -13
  56. package/presets/biome.base.json +100 -0
  57. package/presets/lefthook.yml +11 -0
  58. package/dist/lib/vercel-cli-pin.d.ts +0 -2
  59. package/dist/lib/vercel-cli-pin.js +0 -29
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ const MANIFEST = 'package.json';
3
4
  export const CONTRACT = [
4
5
  { kind: 'file', path: 'src/components/ui/section.astro', reason: 'le sezioni generate la importano' },
5
6
  { kind: 'file', path: 'src/components/ui/container.astro', reason: 'le sezioni e le pagine generate lo importano' },
@@ -27,33 +28,77 @@ export const CONTRACT = [
27
28
  extension: '.ts',
28
29
  reason: 'gen:collection e gen:section scrivono lì gli schemi',
29
30
  },
31
+ {
32
+ kind: 'anchor',
33
+ path: 'src/lib/schemas/common.ts',
34
+ text: 'export const ctaSchema',
35
+ reason: 'lo schema di ogni sezione generata lo importa da ../common',
36
+ },
37
+ {
38
+ kind: 'anchor',
39
+ path: 'src/lib/schemas/common.ts',
40
+ text: 'export function imageSchema',
41
+ reason: "lo schema di una sezione con immagine lo importa da ../common, e lo chiama con l'helper di Astro",
42
+ },
43
+ {
44
+ kind: 'file',
45
+ path: 'src/assets/placeholder.jpg',
46
+ reason: 'il contenuto di una sezione con immagine ci punta appena generato',
47
+ },
30
48
  {
31
49
  kind: 'anchor',
32
50
  path: 'src/content.config.ts',
33
51
  text: 'export const collections',
34
52
  reason: 'gen:collection e gen:section registrano lì la collection',
35
53
  },
54
+ {
55
+ kind: 'dependency',
56
+ name: 'class-variance-authority',
57
+ reason: 'il componente che scrive gen:component lo importa',
58
+ },
59
+ { kind: 'script', name: 'check', reason: 'il post-gen di ogni generatore lo lancia con `pnpm run check`' },
36
60
  ];
37
61
  function hasFileWith(directory, extension) {
38
62
  if (!existsSync(directory) || !statSync(directory).isDirectory())
39
63
  return false;
40
64
  return readdirSync(directory, { recursive: true }).some((entry) => String(entry).endsWith(extension));
41
65
  }
42
- function gapFor(root, requirement) {
66
+ function manifestGap(manifest, requirement) {
67
+ if (requirement.kind === 'script') {
68
+ return manifest.scripts?.[requirement.name] === undefined
69
+ ? { path: MANIFEST, message: `manca lo script \`${requirement.name}\`: ${requirement.reason}` }
70
+ : undefined;
71
+ }
72
+ if (requirement.kind !== 'dependency')
73
+ return undefined;
74
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies };
75
+ return declared[requirement.name] === undefined
76
+ ? { path: MANIFEST, message: `manca la dipendenza \`${requirement.name}\`: ${requirement.reason}` }
77
+ : undefined;
78
+ }
79
+ function pathGap(root, requirement) {
80
+ if (requirement.kind === 'dependency' || requirement.kind === 'script')
81
+ return undefined;
82
+ const gap = (message) => ({ path: requirement.path, message });
43
83
  const target = join(root, requirement.path);
44
84
  if (requirement.kind === 'directory') {
45
- return hasFileWith(target, requirement.extension) ? undefined : `manca, o è vuota: ${requirement.reason}`;
85
+ return hasFileWith(target, requirement.extension) ? undefined : gap(`manca, o è vuota: ${requirement.reason}`);
46
86
  }
47
87
  if (!existsSync(target))
48
- return `manca: ${requirement.reason}`;
88
+ return gap(`manca: ${requirement.reason}`);
49
89
  if (requirement.kind === 'anchor' && !readFileSync(target, 'utf8').includes(requirement.text)) {
50
- return `manca l'ancoraggio \`${requirement.text}\`: ${requirement.reason}`;
90
+ return gap(`manca l'ancoraggio \`${requirement.text}\`: ${requirement.reason}`);
51
91
  }
52
92
  return undefined;
53
93
  }
94
+ function readManifest(root) {
95
+ const target = join(root, MANIFEST);
96
+ return existsSync(target) ? JSON.parse(readFileSync(target, 'utf8')) : {};
97
+ }
54
98
  export function contractGaps(root) {
99
+ const manifest = readManifest(root);
55
100
  return CONTRACT.flatMap((requirement) => {
56
- const message = gapFor(root, requirement);
57
- return message === undefined ? [] : [{ path: requirement.path, message }];
101
+ const gap = pathGap(root, requirement) ?? manifestGap(manifest, requirement);
102
+ return gap === undefined ? [] : [gap];
58
103
  });
59
104
  }
@@ -0,0 +1,10 @@
1
+ import { type Hit } from './cli.ts';
2
+ /** Un file che porta il pin di un pacchetto scaricato al volo, e il suo contenuto. */
3
+ export interface PinSite {
4
+ path: string;
5
+ source: string;
6
+ }
7
+ export interface PinHit extends Hit {
8
+ path: string;
9
+ }
10
+ export declare function pinFindings(pkg: string, latest: string, sites: readonly [PinSite, ...PinSite[]]): PinHit[];
@@ -0,0 +1,48 @@
1
+ import { readLines } from './cli.js';
2
+ const EXACT = /^\d+\.\d+\.\d+$/;
3
+ const escaped = (name) => name.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
4
+ function pinsOf(pkg, sites) {
5
+ const pattern = new RegExp(`(?<![\\w@/.-])${escaped(pkg)}@([\\w.-]+)`);
6
+ return sites.flatMap(({ path, source }) => readLines(source).flatMap(({ n, text }) => {
7
+ const version = pattern.exec(text)?.[1];
8
+ return version === undefined ? [] : [{ path, line: n, version }];
9
+ }));
10
+ }
11
+ const major = (version) => Number(/^(\d+)\./.exec(version)?.[1]);
12
+ // Il pin esatto è il contratto: `vercel@59` ridà una CLI diversa ogni settimana, e un deploy che
13
+ // fallisce non si riproduce. La major più nuova su npm resta una segnalazione, non un obbligo.
14
+ export function pinFindings(pkg, latest, sites) {
15
+ const pins = pinsOf(pkg, sites);
16
+ const home = sites[0].path;
17
+ if (pins.length === 0) {
18
+ return [{ path: home, line: 1, message: `nessun \`${pkg}@<x.y.z>\`: il pin è sparito` }];
19
+ }
20
+ const loose = pins.filter(({ version }) => !EXACT.test(version));
21
+ if (loose.length > 0) {
22
+ return loose.map(({ path, line, version }) => ({
23
+ path,
24
+ line,
25
+ message: `${pkg}@${version}: il pin va scritto per intero, \`x.y.z\``,
26
+ }));
27
+ }
28
+ const versions = [...new Set(pins.map(({ version }) => version))].sort();
29
+ const [pinned = ''] = versions;
30
+ if (versions.length > 1) {
31
+ return pins.map(({ path, line, version }) => ({
32
+ path,
33
+ line,
34
+ message: `${pkg}@${version}: il pin non è lo stesso ovunque (${versions.join(', ')})`,
35
+ }));
36
+ }
37
+ const published = major(latest);
38
+ if (Number.isNaN(published)) {
39
+ return [{ path: home, line: 1, message: `versione pubblicata illeggibile: ${JSON.stringify(latest)}` }];
40
+ }
41
+ if (major(pinned) >= published)
42
+ return [];
43
+ return pins.map(({ path, line }) => ({
44
+ path,
45
+ line,
46
+ message: `${pkg}@${pinned}: su npm è pubblicata la major ${published}`,
47
+ }));
48
+ }
@@ -18,6 +18,8 @@ export type VerifiedRoute = {
18
18
  path: string;
19
19
  type: string;
20
20
  };
21
+ /** `/blog/[slug]` → `/blog/hello-world`: il percorso vero che sta per il pattern. */
22
+ export type Representatives = Readonly<Record<string, string>>;
21
23
  export declare const ERROR_PAGES: readonly string[];
22
24
  export declare const NON_HTML_ROUTES: readonly VerifiedRoute[];
23
25
  export declare function filesWithExtension(dir: string, extension: string): string[];
@@ -26,6 +28,8 @@ export declare function routeOf(htmlPath: string, dist: string): string;
26
28
  export declare function expectedRoutes(pages: readonly PageFile[], pagesDir: string): Expectations;
27
29
  /** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
28
30
  export declare function missingRouteFailures(expected: Expectations, emitted: readonly string[], dist: string): string[];
29
- export declare function auditRoutes(expected: Expectations): string[];
30
- export declare function smokeRoutes(expected: Expectations, nonHtmlRoutes?: readonly VerifiedRoute[]): VerifiedRoute[];
31
+ /** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
32
+ export declare function missingRepresentatives(expected: Expectations, representatives?: Representatives): string[];
33
+ export declare function auditRoutes(expected: Expectations, representatives?: Representatives): string[];
34
+ export declare function smokeRoutes(expected: Expectations, nonHtmlRoutes?: readonly VerifiedRoute[], representatives?: Representatives): VerifiedRoute[];
31
35
  export declare function orphanExceptions(expected: Expectations): string[];
@@ -66,30 +66,39 @@ export function expectedRoutes(pages, pagesDir) {
66
66
  /** [HARD] Guardia fail-open: ogni asserzione per rotta itera sulle pagine emesse, quindi una dist vuota non asserisce niente. */
67
67
  export function missingRouteFailures(expected, emitted, dist) {
68
68
  if (emitted.length === 0) {
69
- return [`${dist} holds no .html file — no route was measured, so the per-route budgets assert nothing`];
69
+ return [`${dist} non contiene nessun .html: nessuna rotta misurata, quindi i budget per rotta non affermano niente`];
70
70
  }
71
71
  const failures = [];
72
72
  const routes = new Set(emitted);
73
73
  for (const { route, file } of expected.exact) {
74
74
  if (!routes.has(route))
75
- failures.push(`missing route ${route} — ${file} is prerendered but emitted no HTML`);
75
+ failures.push(`rotta mancante ${route}: ${file} è prerenderizzata ma non ha emesso HTML`);
76
76
  }
77
77
  for (const { pattern, label, file } of expected.patterns) {
78
78
  if (emitted.some((route) => pattern.test(route)))
79
79
  continue;
80
- failures.push(`missing route ${label} — ${file} is prerendered but getStaticPaths emitted nothing`);
80
+ failures.push(`rotta mancante ${label}: ${file} è prerenderizzata ma getStaticPaths non ha emesso niente`);
81
81
  }
82
82
  return failures;
83
83
  }
84
- // Ordinate: l'ordine nativo di readdirSync cambia da un filesystem all'altro.
85
- export function auditRoutes(expected) {
86
- return expected.exact
87
- .map(({ route }) => route)
88
- .filter((route) => !ERROR_PAGES.includes(route))
84
+ /** I pattern dinamici che `officina.config.ts` non ha ancora mandato nessuno a visitare. */
85
+ export function missingRepresentatives(expected, representatives = {}) {
86
+ return expected.patterns
87
+ .map(({ label }) => label)
88
+ .filter((label) => representatives[label] === undefined)
89
89
  .sort();
90
90
  }
91
- export function smokeRoutes(expected, nonHtmlRoutes = NON_HTML_ROUTES) {
92
- return [...auditRoutes(expected).map((path) => ({ path, type: 'text/html' })), ...nonHtmlRoutes];
91
+ // Ordinate: l'ordine nativo di readdirSync cambia da un filesystem all'altro. Un pattern dinamico
92
+ // entra col percorso vero che il progetto gli ha dato: senza, resta una pagina che nessuno guarda.
93
+ export function auditRoutes(expected, representatives = {}) {
94
+ const exact = expected.exact.map(({ route }) => route).filter((route) => !ERROR_PAGES.includes(route));
95
+ const stand = expected.patterns
96
+ .map(({ label }) => representatives[label])
97
+ .filter((path) => path !== undefined);
98
+ return [...exact, ...stand].sort();
99
+ }
100
+ export function smokeRoutes(expected, nonHtmlRoutes = NON_HTML_ROUTES, representatives = {}) {
101
+ return [...auditRoutes(expected, representatives).map((path) => ({ path, type: 'text/html' })), ...nonHtmlRoutes];
93
102
  }
94
103
  export function orphanExceptions(expected) {
95
104
  const routes = new Set(expected.exact.map(({ route }) => route));
@@ -33,9 +33,9 @@ export async function checkPages({ get, baseUrl }, pages) {
33
33
  const response = await get(`${baseUrl}${path}`);
34
34
  const contentType = response.headers.get('content-type') ?? '';
35
35
  if (response.status !== 200)
36
- results.push(fail(check, `expected 200, got ${response.status}`));
36
+ results.push(fail(check, `atteso 200, ricevuto ${response.status}`));
37
37
  else if (!contentType.includes(type))
38
- results.push(fail(check, `expected a ${type} content-type, got "${contentType}"`));
38
+ results.push(fail(check, `atteso un content-type ${type}, ricevuto "${contentType}"`));
39
39
  else
40
40
  results.push(pass(check));
41
41
  }
@@ -51,29 +51,29 @@ export async function checkSecurityHeaders({ get, baseUrl, securityHeaders }) {
51
51
  response = await get(baseUrl);
52
52
  }
53
53
  catch (error) {
54
- return [fail('security headers', messageOf(error))];
54
+ return [fail('header di sicurezza', messageOf(error))];
55
55
  }
56
56
  const results = Object.entries(securityHeaders).map(([header, expected]) => {
57
57
  const check = `header ${header}`;
58
58
  const value = response.headers.get(header);
59
59
  if (value === null)
60
- return fail(check, 'missing');
60
+ return fail(check, 'assente');
61
61
  if (expected !== null && value !== expected)
62
- return fail(check, `expected "${expected}", got "${value}"`);
62
+ return fail(check, `atteso "${expected}", ricevuto "${value}"`);
63
63
  return pass(check);
64
64
  });
65
65
  // Il noindex `has: host = *.vercel.app` di vercel.json applicato al dominio vero farebbe sparire il sito da ogni indice.
66
- const check = 'no x-robots-tag on the production host';
66
+ const check = 'nessun x-robots-tag sull’host di produzione';
67
67
  const robots = response.headers.get('x-robots-tag');
68
- results.push(robots === null ? pass(check) : fail(check, `present on ${baseUrl}: "${robots}"`));
68
+ results.push(robots === null ? pass(check) : fail(check, `presente su ${baseUrl}: "${robots}"`));
69
69
  return results;
70
70
  }
71
71
  export async function checkBotIdChallenge({ get, baseUrl }) {
72
- const check = 'BotID challenge proxied same-origin';
72
+ const check = 'challenge di BotID servita dalla stessa origine';
73
73
  try {
74
74
  const response = await get(`${baseUrl}${BOTID_CHALLENGE}`);
75
75
  if (response.status !== 200)
76
- return [fail(check, `expected 200 from the rewrite, got ${response.status}`)];
76
+ return [fail(check, `atteso 200 dal rewrite, ricevuto ${response.status}`)];
77
77
  return [pass(check)];
78
78
  }
79
79
  catch (error) {
@@ -82,16 +82,16 @@ export async function checkBotIdChallenge({ get, baseUrl }) {
82
82
  }
83
83
  /** Il 308 da www all'apice, da vercel.json: dipende dal DNS e dal dominio del progetto Vercel, non dal deployment. */
84
84
  export async function checkCanonicalHost({ get, baseUrl, siteUrl }) {
85
- const check = 'www → apex 308';
85
+ const check = 'www → apice 308';
86
86
  if (baseUrl !== siteUrl)
87
- return [skip(check, `base URL is not ${siteUrl}`)];
87
+ return [skip(check, `l’URL di base non è ${siteUrl}`)];
88
88
  try {
89
89
  const response = await get(`https://www.${new URL(siteUrl).host}/`);
90
90
  const location = response.headers.get('location') ?? '';
91
91
  if (response.status !== 308)
92
- return [fail(check, `expected 308, got ${response.status}`)];
92
+ return [fail(check, `atteso 308, ricevuto ${response.status}`)];
93
93
  if (!location.startsWith(siteUrl))
94
- return [fail(check, `location "${location}" does not point at ${siteUrl}`)];
94
+ return [fail(check, `location "${location}" non punta a ${siteUrl}`)];
95
95
  return [pass(check)];
96
96
  }
97
97
  catch (error) {
@@ -100,16 +100,16 @@ export async function checkCanonicalHost({ get, baseUrl, siteUrl }) {
100
100
  }
101
101
  export async function checkTrailingSlash({ get, baseUrl }, pages) {
102
102
  const page = pages.find(({ path, type }) => type === 'text/html' && path !== '/');
103
- const check = 'trailing slash → 308';
103
+ const check = 'barra finale → 308';
104
104
  if (page === undefined)
105
- return [skip(check, 'no HTML page other than / to probe')];
105
+ return [skip(check, 'nessuna pagina HTML oltre a / da sondare')];
106
106
  try {
107
107
  const response = await get(`${baseUrl}${page.path}/`);
108
108
  if (response.status !== 308)
109
- return [fail(check, `expected 308 on ${page.path}/, got ${response.status}`)];
109
+ return [fail(check, `atteso 308 su ${page.path}/, ricevuto ${response.status}`)];
110
110
  const location = response.headers.get('location') ?? '';
111
111
  if (!location.endsWith(page.path))
112
- return [fail(check, `location "${location}" does not point at ${page.path}`)];
112
+ return [fail(check, `location "${location}" non punta a ${page.path}`)];
113
113
  return [pass(check)];
114
114
  }
115
115
  catch (error) {
@@ -0,0 +1,3 @@
1
+ import type { Manifest } from './alignment.ts';
2
+ import type { ContractGap } from './contract.ts';
3
+ export declare function toolingGaps({ devDependencies }: Manifest): ContractGap[];
@@ -0,0 +1,24 @@
1
+ import { peerRange, withinRange } from './versions.js';
2
+ // Strumenti che i preset e le action danno per scontati: senza, il progetto sembra allineato e poi
3
+ // il primo commit o il primo job li cerca e non li trova.
4
+ const REQUIRED = [
5
+ { name: '@biomejs/biome', reason: 'il progetto estende `@elia97/officina/biome`' },
6
+ { name: 'lefthook', reason: 'il preset dei git hook è suo, e `prepare` lo installa' },
7
+ { name: '@commitlint/cli', reason: 'il preset di lefthook lo lancia su ogni messaggio di commit' },
8
+ { name: '@commitlint/config-conventional', reason: 'è la configurazione che commitlint estende' },
9
+ { name: 'fallow', reason: '`check:deadcode` e `check:health` lo lanciano' },
10
+ ];
11
+ const BIOME = '@biomejs/biome';
12
+ const MANIFEST = 'package.json';
13
+ // Il preset usa una regola `nursery`, che fra una major e l'altra di Biome cambia nome o sparisce:
14
+ // l'intervallo che regge lo dichiara il pacchetto, in un posto solo.
15
+ function biomeRangeGap(declared) {
16
+ const range = peerRange(BIOME);
17
+ return withinRange(declared, range) ? undefined : `\`${BIOME}\` è \`${declared}\`: il preset regge \`${range}\``;
18
+ }
19
+ export function toolingGaps({ devDependencies = {} }) {
20
+ const gaps = REQUIRED.filter(({ name }) => devDependencies[name] === undefined).map(({ name, reason }) => ({ path: MANIFEST, message: `\`${name}\` non è fra le devDependencies: ${reason}` }));
21
+ const biome = devDependencies[BIOME];
22
+ const range = biome === undefined ? undefined : biomeRangeGap(biome);
23
+ return range === undefined ? gaps : [...gaps, { path: MANIFEST, message: range }];
24
+ }
@@ -0,0 +1,11 @@
1
+ export type Check = (value: unknown, path: string) => string[];
2
+ export declare function typeName(value: unknown): string;
3
+ export declare const problem: (path: string, expected: string, value: unknown) => string;
4
+ export declare const aString: Check;
5
+ export declare const aNumber: Check;
6
+ export declare const aFunction: Check;
7
+ export declare const aStringOrNull: Check;
8
+ export declare const anArrayOf: (item: Check) => Check;
9
+ export declare const aRecordOf: (item: Check) => Check;
10
+ export declare const oneOf: (expected: string, allowed: readonly unknown[]) => Check;
11
+ export declare const aShape: (fields: Record<string, Check>) => Check;
@@ -0,0 +1,40 @@
1
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
2
+ const NAMES = new Map([
3
+ ['string', 'una stringa'],
4
+ ['number', 'un numero'],
5
+ ['boolean', 'un booleano'],
6
+ ['function', 'una funzione'],
7
+ ['undefined', 'niente'],
8
+ ]);
9
+ export function typeName(value) {
10
+ if (value === null)
11
+ return 'null';
12
+ if (Array.isArray(value))
13
+ return 'un array';
14
+ return NAMES.get(typeof value) ?? 'un oggetto';
15
+ }
16
+ export const problem = (path, expected, value) => `${path}: atteso ${expected}, ricevuto ${typeName(value)}`;
17
+ export const aString = (value, path) => (typeof value === 'string' ? [] : [problem(path, 'una stringa', value)]);
18
+ export const aNumber = (value, path) => (typeof value === 'number' ? [] : [problem(path, 'un numero', value)]);
19
+ export const aFunction = (value, path) => typeof value === 'function' ? [] : [problem(path, 'una funzione', value)];
20
+ export const aStringOrNull = (value, path) => typeof value === 'string' || value === null ? [] : [problem(path, 'una stringa oppure null', value)];
21
+ export const anArrayOf = (item) => (value, path) => Array.isArray(value)
22
+ ? value.flatMap((entry, index) => item(entry, `${path}[${index}]`))
23
+ : [problem(path, 'un array', value)];
24
+ export const aRecordOf = (item) => (value, path) => isRecord(value)
25
+ ? Object.entries(value).flatMap(([key, entry]) => item(entry, `${path}.${key}`))
26
+ : [problem(path, 'un oggetto', value)];
27
+ export const oneOf = (expected, allowed) => (value, path) => allowed.includes(value) ? [] : [problem(path, expected, value)];
28
+ // Una voce non dichiarata è un errore: `sitUrl` al posto di `siteUrl` non deve passare in
29
+ // silenzio, che è il modo esatto in cui una configurazione smette di dire qualcosa.
30
+ export const aShape = (fields) => (value, path) => {
31
+ if (!isRecord(value))
32
+ return [problem(path, 'un oggetto', value)];
33
+ const prefix = path === '' ? '' : `${path}.`;
34
+ return Object.entries(value).flatMap(([key, entry]) => {
35
+ const check = fields[key];
36
+ if (check === undefined)
37
+ return [`${prefix}${key}: voce sconosciuta`];
38
+ return entry === undefined ? [] : check(entry, `${prefix}${key}`);
39
+ });
40
+ };
@@ -0,0 +1,5 @@
1
+ export declare const packageVersion: () => string;
2
+ /** L'intervallo che il pacchetto dichiara di reggere: sta in un posto solo, le sue peerDependencies. */
3
+ export declare const peerRange: (name: string) => string;
4
+ /** Vero solo se ogni versione che `spec` può installare sta dentro `range`, scritto `>=x.y.z <a.b.c`. */
5
+ export declare function withinRange(spec: string, range: string): boolean;
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ const self = () => JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
4
+ export const packageVersion = () => self().version;
5
+ /** L'intervallo che il pacchetto dichiara di reggere: sta in un posto solo, le sue peerDependencies. */
6
+ export const peerRange = (name) => self().peerDependencies?.[name] ?? '';
7
+ const EXACT = /(\d+)\.(\d+)\.(\d+)/;
8
+ function parseVersion(text) {
9
+ const found = EXACT.exec(text);
10
+ return found === null ? undefined : [Number(found[1]), Number(found[2]), Number(found[3])];
11
+ }
12
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
13
+ // Il limite superiore escluso di ciò che lo specificatore può installare: `^` sale fino alla major
14
+ // dopo (fino alla minor dopo, sotto lo zero), `~` fino alla minor dopo, una versione esatta a sé.
15
+ function ceiling(spec, [major, minor, patch]) {
16
+ if (spec.startsWith('^'))
17
+ return major === 0 ? [0, minor + 1, 0] : [major + 1, 0, 0];
18
+ if (spec.startsWith('~'))
19
+ return [major, minor + 1, 0];
20
+ return EXACT.test(spec.replace(/^[=v]/, '')) && !/[<>|*x\s]/.test(spec) ? [major, minor, patch + 1] : undefined;
21
+ }
22
+ function parseBounds(range) {
23
+ const [low = '', high = '', extra] = range.split(' ');
24
+ if (extra !== undefined || !low.startsWith('>=') || !high.startsWith('<'))
25
+ return undefined;
26
+ const min = parseVersion(low);
27
+ const max = parseVersion(high);
28
+ return min === undefined || max === undefined ? undefined : { min, max };
29
+ }
30
+ /** Vero solo se ogni versione che `spec` può installare sta dentro `range`, scritto `>=x.y.z <a.b.c`. */
31
+ export function withinRange(spec, range) {
32
+ const bounds = parseBounds(range);
33
+ const floor = parseVersion(spec);
34
+ if (bounds === undefined || floor === undefined)
35
+ return false;
36
+ const top = ceiling(spec, floor);
37
+ return top === undefined ? false : compare(floor, bounds.min) >= 0 && compare(top, bounds.max) <= 0;
38
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProjectFiles } from './alignment.ts';
2
+ import type { ContractGap } from './contract.ts';
3
+ export declare const mentions: (source: string | undefined, needle: string) => boolean;
4
+ /** Le action del progetto devono stare alla stessa versione del pacchetto installato. */
5
+ export declare function workflowGaps({ read }: ProjectFiles, version: string): ContractGap[];
6
+ export declare function dependabotGaps({ read }: ProjectFiles): ContractGap[];
@@ -0,0 +1,70 @@
1
+ // I workflow restano una ricerca testuale — non c'è un parser YAML nel pacchetto — ma una riga
2
+ // commentata non deve soddisfare niente: è il modo più facile per avere un progetto «a posto»
3
+ // che in CI non lancia nessuno di questi passi.
4
+ const liveLines = (source) => source.split(/\r?\n/).filter((line) => !line.trimStart().startsWith('#'));
5
+ export const mentions = (source, needle) => source !== undefined && liveLines(source).some((line) => line.includes(needle));
6
+ const ACTIONS = [
7
+ { workflow: '.github/workflows/ci.yml', action: 'ci' },
8
+ { workflow: '.github/workflows/ci.yml', action: 'review' },
9
+ { workflow: '.github/workflows/deploy.yml', action: 'deploy' },
10
+ { workflow: '.github/workflows/lighthouse.yml', action: 'lighthouse' },
11
+ ];
12
+ // Un tag di versione o uno SHA intero: `@main` farebbe girare in produzione passi mai collaudati.
13
+ const TAG = /^v\d+\.\d+\.\d+$/;
14
+ const SHA = /^[0-9a-f]{40}$/;
15
+ const COMMENTED_VERSION = /#\s*(v\d+\.\d+\.\d+)/;
16
+ function refOf(source, name) {
17
+ const pattern = new RegExp(`${name}@(\\S+)`);
18
+ for (const line of liveLines(source)) {
19
+ const ref = pattern.exec(line)?.[1];
20
+ if (ref !== undefined)
21
+ return { line, ref };
22
+ }
23
+ return undefined;
24
+ }
25
+ function refGap(line, ref, name, expected) {
26
+ if (TAG.test(ref)) {
27
+ return ref === expected ? undefined : `\`${name}@${ref}\`: il pacchetto installato è la ${expected.slice(1)}`;
28
+ }
29
+ if (!SHA.test(ref))
30
+ return `\`${name}@${ref}\`: il riferimento va fissato a un tag di versione o a uno SHA`;
31
+ const declared = COMMENTED_VERSION.exec(line)?.[1];
32
+ if (declared === undefined) {
33
+ return `\`${name}@${ref.slice(0, 7)}…\`: uno SHA non dice che versione è — scrivila nel commento (\`# ${expected}\`)`;
34
+ }
35
+ return declared === expected
36
+ ? undefined
37
+ : `\`${name}@${ref.slice(0, 7)}… ${declared}\`: il pacchetto installato è la ${expected.slice(1)}`;
38
+ }
39
+ function actionGap(source, action, version) {
40
+ const name = `Elia97/officina/actions/${action}`;
41
+ if (source === undefined)
42
+ return `manca: i suoi passi arrivano da \`${name}\``;
43
+ const found = refOf(source, name);
44
+ return found === undefined ? `non usa \`${name}\`` : refGap(found.line, found.ref, name, `v${version}`);
45
+ }
46
+ /** Le action del progetto devono stare alla stessa versione del pacchetto installato. */
47
+ export function workflowGaps({ read }, version) {
48
+ return ACTIONS.flatMap(({ workflow, action }) => {
49
+ const message = actionGap(read(workflow), action, version);
50
+ return message === undefined ? [] : [{ path: workflow, message }];
51
+ });
52
+ }
53
+ const DEPENDABOT = '.github/dependabot.yml';
54
+ // Senza il gruppo, Dependabot apre due PR — una per il pacchetto npm, una per le action — e il
55
+ // controllo di coerenza qui sopra le boccia entrambe, perché nessuna delle due allinea l'altra.
56
+ const GROUPED = [
57
+ { needle: 'multi-ecosystem-groups', reason: 'il pacchetto e le sue action si alzano in una PR sola' },
58
+ { needle: '@elia97/officina', reason: 'il gruppo deve prendere il pacchetto da npm' },
59
+ { needle: 'Elia97/officina/*', reason: 'il gruppo deve prendere le action da github-actions' },
60
+ ];
61
+ export function dependabotGaps({ read }) {
62
+ const source = read(DEPENDABOT);
63
+ if (source === undefined) {
64
+ return [{ path: DEPENDABOT, message: 'manca: senza, il pacchetto e le sue action si alzano separati' }];
65
+ }
66
+ return GROUPED.filter(({ needle }) => !mentions(source, needle)).map(({ needle, reason }) => ({
67
+ path: DEPENDABOT,
68
+ message: `non nomina \`${needle}\`: ${reason}`,
69
+ }));
70
+ }
@@ -25,9 +25,9 @@ detect_chrome() {
25
25
  }
26
26
 
27
27
  if ! CHROME_PATH="$(detect_chrome)"; then
28
- echo "✗ no usable Linux Chrome/Chromium found." >&2
29
- echo " Install one with: pnpm dlx playwright@latest install chromium" >&2
30
- echo " Or point at the binary: CHROME_PATH=/path/to/chrome pnpm run lhci:local" >&2
28
+ echo "✗ nessun Chrome/Chromium per Linux utilizzabile." >&2
29
+ echo " Installane uno con: pnpm dlx playwright@latest install chromium" >&2
30
+ echo " Oppure indica il binario: CHROME_PATH=/percorso/di/chrome pnpm run lhci:local" >&2
31
31
  exit 1
32
32
  fi
33
33
  export CHROME_PATH
@@ -38,24 +38,24 @@ SERVER_PID=''
38
38
  cleanup() {
39
39
  if [[ -n "${SERVER_PID}" ]]; then
40
40
  kill -- "-${SERVER_PID}" 2>/dev/null || kill "${SERVER_PID}" 2>/dev/null || true
41
- pkill -f "serve@14 .*--listen ${PORT}" 2>/dev/null || true
41
+ pkill -f "serve@14.2.6 .*--listen ${PORT}" 2>/dev/null || true
42
42
  fi
43
43
  find . -maxdepth 1 \( -name '*wsl.localhost*' -o -name 'undefined*' \) -exec rm -rf {} + 2>/dev/null || true
44
44
  }
45
45
  trap cleanup EXIT
46
46
 
47
- echo "→ production-equivalent build in ${OUT_DIR}"
47
+ echo "→ build equivalente alla produzione in ${OUT_DIR}"
48
48
  VERCEL_ENV=production pnpm build --outDir "${OUT_DIR}"
49
49
 
50
50
  if ! grep -qx 'Allow: /' "${OUT_DIR}/client/robots.txt"; then
51
- echo "✗ ${OUT_DIR}/client/robots.txt has no 'Allow: /' — the build is not production-equivalent." >&2
51
+ echo "✗ ${OUT_DIR}/client/robots.txt non ha 'Allow: /': la build non equivale alla produzione." >&2
52
52
  head -3 "${OUT_DIR}/client/robots.txt" >&2
53
53
  exit 1
54
54
  fi
55
55
  echo "✓ robots.txt: Allow: /"
56
56
 
57
- echo "→ starting the static server on :${PORT}"
58
- setsid pnpm dlx serve@14 "${OUT_DIR}/client" --listen "${PORT}" --no-clipboard >/dev/null 2>&1 &
57
+ echo "→ avvio del server statico su :${PORT}"
58
+ setsid pnpm dlx serve@14.2.6 "${OUT_DIR}/client" --listen "${PORT}" --no-clipboard >/dev/null 2>&1 &
59
59
  SERVER_PID=$!
60
60
 
61
61
  for _ in $(seq 1 60); do
@@ -63,11 +63,11 @@ for _ in $(seq 1 60); do
63
63
  sleep 1
64
64
  done
65
65
  if ! curl -fs -o /dev/null "http://localhost:${PORT}/"; then
66
- echo "✗ the server did not answer on :${PORT} within 60s." >&2
67
- echo " Retry on another port: LHCI_PORT=4400 pnpm run lhci:local" >&2
66
+ echo "✗ il server non ha risposto su :${PORT} entro 60s." >&2
67
+ echo " Riprova su un’altra porta: LHCI_PORT=4400 pnpm run lhci:local" >&2
68
68
  exit 1
69
69
  fi
70
- echo "✓ server ready"
70
+ echo "✓ server pronto"
71
71
 
72
72
  rm -rf "${REPORT_DIR}"
73
73
 
@@ -79,7 +79,7 @@ LH_CHROME_FLAGS='--no-sandbox --disable-dev-shm-usage' \
79
79
  node "${OFFICINA_BIN}" check lighthouse
80
80
 
81
81
  echo
82
- echo "→ score per URL (median over ${LHCI_RUNS:-1} run(s), the way LHCI asserts)"
82
+ echo "→ punteggio per URL (mediana su ${LHCI_RUNS:-1} giro/i, come la calcola LHCI)"
83
83
  node -e '
84
84
  const fs = require("fs")
85
85
  const dir = process.argv[1]
@@ -0,0 +1,2 @@
1
+ export { NON_HTML_ROUTES } from './lib/routes.ts';
2
+ export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.ts';
package/dist/smoke.js ADDED
@@ -0,0 +1,4 @@
1
+ // I pezzi con cui un progetto compone i propri controlli di smoke: `smoke.checks` sostituisce la
2
+ // lista del pacchetto, quindi chi ne toglie uno ricompone la lista da qui.
3
+ export { NON_HTML_ROUTES } from './lib/routes.js';
4
+ export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.js';