@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
package/README.md CHANGED
@@ -18,7 +18,6 @@ pnpm add -D @elia97/officina
18
18
  "check:routes": "officina check routes",
19
19
  "check:roadmap": "officina check roadmap",
20
20
  "check:placeholders": "officina check placeholders",
21
- "check:vercel-cli": "officina check vercel-cli",
22
21
  "perf:bundle": "officina check bundle",
23
22
  "smoke:prod": "officina check smoke",
24
23
  "analytics:verify": "officina check analytics",
@@ -43,14 +42,15 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
43
42
  |---|---|---|
44
43
  | `check bundle` | il JavaScript e il CSS di `dist/client`, gzip, contro un budget per rotta | `src/pages` |
45
44
  | `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
46
- | `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi di `src/lib/analytics/link-tracking.ts` | — |
45
+ | `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
47
46
  | `check lighthouse [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; `--local` fa build, server e Chrome da sé | `src/pages` |
48
- | `gen icons` | le icone del manifest, disegnate da `public/favicon.svg` | — |
47
+ | `gen icons` | le icone del manifest, disegnate dal favicon SVG in `public/` del progetto | — |
49
48
 
50
49
  Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
51
50
 
52
51
  ```ts
53
- import { type Budget, DEFAULT_CHECKS, defineConfig, type SmokeCheck } from '@elia97/officina'
52
+ import { type Budget, defineConfig, type SmokeCheck } from '@elia97/officina'
53
+ import { DEFAULT_CHECKS } from '@elia97/officina/smoke'
54
54
 
55
55
  import { SITE } from './src/lib/site.ts'
56
56
 
@@ -62,12 +62,96 @@ export default defineConfig({
62
62
  icons: { background: SITE.themeColor.light },
63
63
  bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
64
64
  smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
65
+ features: { analytics: 'required', roadmap: false },
66
+ routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
65
67
  })
66
68
  ```
67
69
 
68
- Ogni voce è facoltativa, tranne `siteUrl` per `check smoke` e `icons.background` per `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi non ha BotID la ricompone senza `checkBotIdChallenge`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`. Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`.
70
+ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stanno nei sottopercorsi, così la radice resta la sola cosa che serve conoscere per configurarsi.
69
71
 
70
- `sharp` è una dipendenza facoltativa del progetto, non del pacchetto: serve solo a `gen icons`, che senza lo dice ed esce 1. `ICON_SPECS` si importa senza caricarlo, per il test che confronta le icone con il manifest del sito.
72
+ | Sottopercorso | Cosa esporta |
73
+ |---|---|
74
+ | `@elia97/officina/smoke` | `DEFAULT_CHECKS`, i cinque controlli, `SECURITY_HEADERS`, `NON_HTML_ROUTES` |
75
+ | `@elia97/officina/bundle` | `CSS_BUDGET_GZIP` |
76
+ | `@elia97/officina/icons` | `ICON_SPECS` |
77
+
78
+ `siteUrl` serve a `check smoke` e `icons.background` a `gen icons`. I budget del progetto vengono prima del default di 20 KB, nell'ordine in cui sono scritti; `smoke.checks` sostituisce la lista del pacchetto, quindi chi non ha BotID la ricompone senza `checkBotIdChallenge`; `smoke.securityHeaders` e `smoke.nonHtmlRoutes` sostituiscono `SECURITY_HEADERS` e `NON_HTML_ROUTES`.
79
+
80
+ `features` dice quali controlli il progetto pretende: `'required'` fa fallire il gate quando ciò che deve guardare non c'è, `false` lo spegne dicendolo. Una voce non dichiarata vale `'required'`, e `doctor` la segnala: un gate che esce 0 su un container GTM che non esiste non afferma niente. `routes.representatives` dà a ogni pattern dinamico di `src/pages` un percorso vero, e da lì smoke e Lighthouse guardano anche quelle pagine.
81
+
82
+ Il file lo carica Node, che fuori da `node_modules` toglie i tipi da sé: niente `enum`, niente alias `@/`. Due file `officina.config.*` insieme sono un errore, come un file senza default export; il resto lo valida `loadConfig` a runtime, nominando il percorso della voce sbagliata — `bundle.cssMaxGzip: atteso un numero, ricevuto una stringa` — e una voce sconosciuta, perché `sitUrl` al posto di `siteUrl` non deve passare in silenzio.
83
+
84
+ `sharp` è una dipendenza facoltativa del progetto, non del pacchetto: serve solo a `gen icons`, che senza lo dice ed esce 1. `@biomejs/biome` invece è una peer dependency con l'intervallo che il preset regge, e `doctor` confronta la versione del progetto con quello.
85
+
86
+ ## Preset di configurazione
87
+
88
+ Le regole che valgono per tutti i progetti stanno nel pacchetto; il progetto estende e tiene solo le proprie eccezioni.
89
+
90
+ ```json
91
+ {
92
+ "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
93
+ "extends": ["@elia97/officina/biome"]
94
+ }
95
+ ```
96
+
97
+ ```yaml
98
+ # lefthook.yml
99
+ extends:
100
+ - node_modules/@elia97/officina/presets/lefthook.yml
101
+ ```
102
+
103
+ ## Action per i workflow
104
+
105
+ I passi dei workflow stanno in `actions/` di questo repository, come composite action: girano dentro il job di chi le chiama, quindi il nome del check e l'`environment` restano del progetto. Il riferimento va fissato a un tag di versione o a uno SHA, mai a `@main`, e deve essere la stessa versione del pacchetto installato — `doctor` lo verifica.
106
+
107
+ | Action | Passi | Cosa resta nel workflow del progetto |
108
+ |---|---|---|
109
+ | `actions/ci` | checkout, node, install, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
110
+ | `actions/review` | `fallow review` sul diff contro il merge-base | il job informativo |
111
+ | `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel passati in `with:`, il job che controlla se i segreti ci sono |
112
+ | `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
113
+
114
+ ```yaml
115
+ jobs:
116
+ deploy:
117
+ runs-on: ubuntu-latest
118
+ environment:
119
+ name: production
120
+ url: ${{ steps.deploy.outputs.url }}
121
+ steps:
122
+ - uses: Elia97/officina/actions/deploy@v0.4.0
123
+ id: deploy
124
+ with:
125
+ ref: ${{ inputs.ref }}
126
+ vercel-token: ${{ secrets.VERCEL_TOKEN }}
127
+ vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
128
+ vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
129
+ ```
130
+
131
+ I tre segreti sono input obbligatori e l'action li mette nell'`env` dei soli tre passi che chiamano `vercel`. Nell'`env` del job li vedrebbero anche `pnpm install` e gli script di installazione di ogni dipendenza, che girano codice di terzi con in mano un token di deploy.
132
+
133
+ Quello che si scarica al volo è fissato a una versione esatta, in un posto solo: la CLI di Vercel in `actions/deploy/action.yml`, `@lhci/cli` in `src/checks/lighthouse.ts`, `serve` in `src/sh/lhci-local.sh`. Che i primi due siano esatti, uguali ovunque e non indietro di una major su npm lo guarda ogni lunedì `.github/workflows/vercel-cli.yml`, con `officina check vercel-cli`.
134
+
135
+ Perché le action non restino ferme per sempre, Dependabot di questo repository copre l'ecosistema `github-actions` anche dentro `/actions/*`, dove le action di terze parti sono fissate allo SHA intero con la versione nel commento.
136
+
137
+ ## doctor
138
+
139
+ `officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli ancoraggi delle pagine a sezioni e dei dizionari, gli script, le dipendenze e gli strumenti di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow di GitHub e Dependabot, `officina.config.ts`. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
140
+
141
+ Le prime due sezioni rispondono a due domande diverse. I punti di aggancio sono l'elenco di `src/lib/contract.ts`, e chiedono che una cosa esista: i moduli che il codice generato importa, le cartelle in cui i generatori scrivono, `class-variance-authority` fra le dipendenze, lo script `check` che il post-gen lancia. Gli ancoraggi guardano dentro quei file, e chiedono che abbiano ancora la forma su cui l'iniezione conta:
142
+
143
+ - per ogni collection a sezioni registrata in `src/content.config.ts` — riconosciuta dall'import di `<nome>CollectionSchema` da `@/lib/schemas/<nome>`, non dai marcatori, che sono proprio ciò che può sparire — la funzione `<nome>CollectionSchema`, la sua `z.discriminatedUnion` sopra un array letterale, il parametro non destrutturato che una sezione con immagine riceve, il `return { … }` di primo livello di `get<Nome>Sections`, e una sola pagina sotto `src/pages/` che porti `{/* @gen:<nome>-sections */}` e, su quella, `// @gen:<nome>-imports`;
144
+ - per ogni dizionario di `src/i18n/strings/`, il suo `export const <lingua> = { … } as const`.
145
+
146
+ Sono gli stessi controlli del pre-volo dei generatori, non una copia: `doctor` chiama `assertSectionAnchors` e `assertDictionaries`, che vivono in `src/gen/` insieme all'iniezione. Al pre-volo resta ciò che dipende dal nome di quello che sta per nascere — la sezione già nell'unione, l'identificatore già preso nel barrel o nel frontmatter, la chiave già nel dizionario, il file che esiste già — e che quindi si può chiedere solo al lancio del generatore.
147
+
148
+ Le altre sezioni misurano che il progetto e il pacchetto si muovano insieme:
149
+
150
+ - **stessa versione**: il riferimento delle action nei workflow — `@v<x.y.z>`, oppure uno SHA con `# v<x.y.z>` nel commento — deve essere la versione del pacchetto installato. Un progetto col pacchetto alla 0.8 e le action alla 0.6 non è un progetto allineato;
151
+ - **una PR sola**: il `.github/dependabot.yml` del progetto deve avere un gruppo `multi-ecosystem-groups` che prende `@elia97/officina` da npm e `Elia97/officina/*` da `github-actions`. Senza, Dependabot ne apre due e il controllo qui sopra le boccia entrambe;
152
+ - **gli strumenti che preset e action danno per scontati**: `@biomejs/biome`, `lefthook`, `@commitlint/cli`, `@commitlint/config-conventional` e `fallow` fra le `devDependencies`, con la versione di Biome dentro l'intervallo che il preset dichiara di reggere.
153
+
154
+ `package.json`, `.claude/settings.json`, `biome.json` e `.mcp.json` si leggono come JSON, non cercandoci dentro delle stringhe: `"hooks"` in un valore qualunque non è un blocco `hooks`. I workflow restano una ricerca testuale — non c'è un parser YAML nel pacchetto — ma una riga commentata non soddisfa nessun controllo.
71
155
 
72
156
  ## Sviluppo
73
157
 
@@ -76,24 +160,33 @@ pnpm install
76
160
  pnpm test # vitest; i test che leggono dal disco girano in test/fixture-project/
77
161
  pnpm typecheck
78
162
  pnpm build # tsc → dist/, l'unica cosa che viene pubblicata
163
+ pnpm test:pack # pnpm pack, poi il tarball installato in un progetto vuoto
79
164
  ```
80
165
 
166
+ `ci`, `build` e `test:pack` girano anche in `.github/workflows/ci.yml`, su ogni pull request e su ogni push a `main`.
167
+
81
168
  I sorgenti sono TypeScript, ma il pacchetto pubblica JavaScript: Node toglie i tipi al volo solo fuori da `node_modules`. I test con `--diff` hanno bisogno di almeno un commit su `main`.
82
169
 
83
170
  ## Pubblicare
84
171
 
85
172
  ```sh
86
- pnpm version <patch|minor|major>
87
- pnpm publish # prepublishOnly lancia typecheck, test e build
173
+ pnpm version <patch|minor|major> # scrive package.json e crea il tag
174
+ git push --follow-tags
88
175
  ```
89
176
 
177
+ Il resto lo fa `.github/workflows/release.yml`, sul push di un tag `v*`: controlla che il tag corrisponda a `version`, rilancia il gate, la build e il test del tarball, pubblica su npm con trusted publishing (OIDC) e provenance, e crea la release su GitHub con le note generate. Niente `pnpm publish` dal terminale: quello che finisce su npm esce da un tag, e da nient'altro.
178
+
179
+ Le action si richiamano per tag, quindi **una versione senza tag non è adottabile**: `doctor` chiede ai progetti la stessa versione del pacchetto installato, e un tag che non esiste non si può scrivere in un workflow.
180
+
181
+ Una volta sola, a mano: su npmjs.com il trusted publisher per `Elia97/officina` e `release.yml`; su GitHub un ruleset sui tag `v*` che ne vieta modifica e cancellazione.
182
+
90
183
  ## Generatori
91
184
 
92
185
  `officina gen` avvolge plop con i generatori e i template del pacchetto, e scrive nel progetto da cui lo lanci: `section`, `page`, `component`, `collection`. `plop` e `ts-morph` sono dipendenze del pacchetto, non del progetto.
93
186
 
94
- I generatori scrivono codice che deve incastrarsi nello scaffold, quindi il progetto deve avere i punti di aggancio che si aspettano: i moduli che il codice generato importa e i file in cui iniettano. L'elenco è `src/lib/contract.ts`, e `officina doctor` dice, in qualunque repository, cosa manca. Finché manca qualcosa il pre-volo del generatore si ferma prima di scrivere un solo file.
187
+ I generatori scrivono codice che deve incastrarsi nello scaffold, quindi il progetto deve avere i punti di aggancio che si aspettano: i moduli che il codice generato importa, l'elenco è `src/lib/contract.ts`, e i file in cui iniettano, con la forma su cui l'iniezione conta. Sono le prime due sezioni di `officina doctor`. Finché manca qualcosa il pre-volo del generatore si ferma prima di scrivere un solo file.
95
188
 
96
- Un progetto aggiunge i propri generatori con un file `officina.generators.mjs` nella radice, con la firma di un plopfile: `export default function (plop)`.
189
+ Un progetto aggiunge i propri generatori con un file `officina.generators.mjs` nella radice, con la firma di un plopfile: `export default function (plop)`. È un'estensione avanzata e non è parte dell'API del pacchetto: è legata alla firma di plop, quindi una major di plop può romperla.
97
190
 
98
191
  I test dei generatori girano sui punti di aggancio del progetto di prova, copiati da `vetrina`. Che un progetto vero li abbia ancora lo verifica `doctor`, non i test.
99
192
 
package/dist/bin.js CHANGED
File without changes
@@ -0,0 +1 @@
1
+ export { CSS_BUDGET_GZIP } from './lib/bundle-budget.ts';
package/dist/bundle.js ADDED
@@ -0,0 +1 @@
1
+ export { CSS_BUDGET_GZIP } from './lib/bundle-budget.js';
@@ -4,7 +4,7 @@
4
4
  import { readFileSync } from 'node:fs';
5
5
  import process from 'node:process';
6
6
  import { coverageOf, extractLinkEvents } from '../lib/analytics-coverage.js';
7
- import { loadConfig } from '../lib/config.js';
7
+ import { isRequired, loadConfig } from '../lib/config.js';
8
8
  import { extractTriggers, googleTagIds, parseContainerData } from '../lib/gtm-container.js';
9
9
  const LINK_TRACKING = 'src/lib/analytics/link-tracking.ts';
10
10
  const TIMEOUT_MS = 15_000;
@@ -18,30 +18,36 @@ function reportCoverage(coverage) {
18
18
  }
19
19
  const missing = coverage.filter(({ covered }) => !covered);
20
20
  if (missing.length > 0) {
21
- console.error(`\n✗ ${missing.length} event(s) the site pushes and no trigger listens for.`);
21
+ console.error(`\n✗ ${missing.length} evento/i che il sito emette e nessun trigger ascolta.`);
22
22
  return 1;
23
23
  }
24
- console.log('\n✓ Every event the site pushes has a trigger.');
24
+ console.log('\n✓ Ogni evento che il sito emette ha un trigger.');
25
25
  return 0;
26
26
  }
27
27
  export async function main(args = []) {
28
- const { analytics = {} } = await loadConfig(process.cwd());
28
+ const config = await loadConfig(process.cwd());
29
+ const { analytics = {} } = config;
29
30
  const gtmId = resolveGtmId(args);
30
31
  if (gtmId === null) {
31
- console.log('No GTM container to check: pass one as an argument or set PUBLIC_GTM_ID.');
32
- return 0;
32
+ if (!isRequired(config, 'analytics')) {
33
+ console.log('check:analytics — spento da `features.analytics: false`.');
34
+ return 0;
35
+ }
36
+ console.error('\n✗ Nessun container GTM da controllare: passane uno come argomento, imposta PUBLIC_GTM_ID,\n' +
37
+ ' oppure dichiara `features.analytics: false` se il progetto non ha analytics.\n');
38
+ return 1;
33
39
  }
34
40
  const response = await fetch(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`, {
35
41
  signal: AbortSignal.timeout(TIMEOUT_MS),
36
42
  });
37
43
  if (!response.ok) {
38
- console.error(`✗ container ${gtmId} unreadable: HTTP ${response.status}`);
44
+ console.error(`✗ container ${gtmId} illeggibile: HTTP ${response.status}`);
39
45
  return 1;
40
46
  }
41
47
  const container = parseContainerData(await response.text());
42
48
  const source = readFileSync(analytics.linkTracking ?? LINK_TRACKING, 'utf8');
43
49
  const coverage = coverageOf(extractLinkEvents(source), extractTriggers(container));
44
- console.log(`\nGTM ${gtmId} — measurement IDs: ${googleTagIds(container).join(', ') || '—'}\n`);
50
+ console.log(`\nGTM ${gtmId} — ID di misurazione: ${googleTagIds(container).join(', ') || '—'}\n`);
45
51
  return reportCoverage(coverage);
46
52
  }
47
53
  if (import.meta.main)
@@ -29,16 +29,24 @@ function measurePages(chunks, budgets) {
29
29
  return filesWithExtension(DIST, '.html')
30
30
  .map((htmlPath) => {
31
31
  const route = routeOf(htmlPath, DIST);
32
- const reached = staticClosure(htmlEntries(readFileSync(htmlPath, 'utf8')), chunks);
32
+ const { reached, unknown } = staticClosure(htmlEntries(readFileSync(htmlPath, 'utf8')), chunks);
33
33
  return {
34
34
  route,
35
35
  gzip: total(reached),
36
36
  deferredGzip: total(deferredClosure(reached, chunks)),
37
37
  budget: budgetFor(route, budgets),
38
+ unknown: [...unknown].sort(),
38
39
  };
39
40
  })
40
41
  .sort((a, b) => b.gzip - a.gzip);
41
42
  }
43
+ // Zero JavaScript su una rotta è un esito normale in Astro; un nome citato che fra i chunk non c'è
44
+ // vuol dire che la misura non copre tutto il bundle, e allora non afferma niente.
45
+ function unknownChunkFailures(pages) {
46
+ return pages
47
+ .filter((page) => page.unknown.length > 0)
48
+ .map((page) => `${page.route}: formato del bundle non riconosciuto — ${page.unknown.join(', ')} non è in ${ASSETS}`);
49
+ }
42
50
  function overBudgetFailures(pages) {
43
51
  return pages
44
52
  .filter((page) => page.gzip > page.budget.maxGzip)
@@ -48,18 +56,18 @@ function overBudgetFailures(pages) {
48
56
  });
49
57
  }
50
58
  function printPages(pages, width) {
51
- console.log('\nBundle budget — client JS per route (gzip, static closure)\n');
52
- console.log(`${'ROUTE'.padEnd(width)} ${'STATIC'.padStart(9)} ${'BUDGET'.padStart(9)} ${'DEFERRED'.padStart(9)}`);
59
+ console.log('\nBudget di bundle — JS del client per rotta (gzip, chiusura statica)\n');
60
+ console.log(`${'ROTTA'.padEnd(width)} ${'STATICO'.padStart(9)} ${'BUDGET'.padStart(9)} ${'DIFFERITO'.padStart(9)}`);
53
61
  for (const page of pages) {
54
62
  const deferred = page.deferredGzip > 0 ? gz(page.deferredGzip) : '—';
55
63
  const line = `${page.route.padEnd(width)} ${gz(page.gzip).padStart(9)} ${gz(page.budget.maxGzip).padStart(9)} ${deferred.padStart(9)}`;
56
64
  console.log(page.gzip > page.budget.maxGzip ? `${line} ✗` : line);
57
65
  }
58
- console.log('\nDEFERRED = reachable only through `await import()` (loaded after paint, behind a runtime guard).');
66
+ console.log('\nDIFFERITO = raggiungibile solo da `await import()` (caricato dopo il primo paint, dietro una guardia a runtime).');
59
67
  }
60
68
  function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
61
69
  const worst = heaviestStylesheet(stylesheets);
62
- console.log(`\nCSS (render-blocking, heaviest sheet a route links)`);
70
+ console.log(`\nCSS (blocca il rendering: il foglio più pesante che una rotta collega)`);
63
71
  for (const sheet of [...stylesheets].sort((a, b) => b.gzip - a.gzip)) {
64
72
  const mark = sheet === worst && failed ? ' ✗' : '';
65
73
  console.log(` ${sheet.file.padEnd(width - 2)} ${gz(sheet.gzip).padStart(9)} ${gz(cssMaxGzip).padStart(9)}${mark}`);
@@ -68,7 +76,7 @@ function printStylesheets(stylesheets, cssMaxGzip, failed, width) {
68
76
  function printSsrNote(expected) {
69
77
  if (expected.ssr.length === 0)
70
78
  return;
71
- console.log(`\nNOTE ${expected.ssr.length} page(s) opted out with \`export const prerender = false\` and are outside this budget:`);
79
+ console.log(`\nNOTA ${expected.ssr.length} pagina/e con \`export const prerender = false\`, fuori da questo budget:`);
72
80
  for (const file of expected.ssr)
73
81
  console.log(` - ${file}`);
74
82
  }
@@ -78,8 +86,8 @@ export async function main() {
78
86
  const pages = measurePages(readChunks(), bundle.budgets ?? []);
79
87
  const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
80
88
  const failures = missingRouteFailures(expected, pages.map((page) => page.route), DIST);
81
- failures.push(...overBudgetFailures(pages));
82
- const width = Math.max('ROUTE'.length, ...pages.map((p) => p.route.length));
89
+ failures.push(...unknownChunkFailures(pages), ...overBudgetFailures(pages));
90
+ const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
83
91
  printPages(pages, width);
84
92
  const stylesheets = readStylesheets();
85
93
  const cssFailure = cssBudgetFailure(stylesheets, cssMaxGzip);
@@ -88,10 +96,10 @@ export async function main() {
88
96
  printStylesheets(stylesheets, cssMaxGzip, cssFailure !== null, width);
89
97
  printSsrNote(expected);
90
98
  if (failures.length > 0) {
91
- console.error(`\n✗ Bundle budget:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
99
+ console.error(`\n✗ Budget di bundle:\n${failures.map((f) => ` - ${f}`).join('\n')}\n`);
92
100
  return 1;
93
101
  }
94
- console.log('\n✓ Bundle budget respected on every expected route.\n');
102
+ console.log('\n✓ Budget di bundle rispettato su ogni rotta attesa.\n');
95
103
  return 0;
96
104
  }
97
105
  if (import.meta.main)
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export declare function main(): number;
2
+ export declare function main(): Promise<number>;
@@ -1,15 +1,79 @@
1
1
  #!/usr/bin/env node
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import process from 'node:process';
5
+ import { dependencyGaps, leftoverGaps, presetGaps, scriptGaps, } from '../lib/alignment.js';
6
+ import { anchorGaps } from '../lib/anchors.js';
3
7
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
4
- import { CONTRACT, contractGaps } from '../lib/contract.js';
5
- export function main() {
6
- const findings = contractGaps(process.cwd()).map((gap) => ({ ...gap, severity: 'error' }));
7
- console.log(`\nofficina doctor — ${CONTRACT.length} punti di aggancio dei generatori\n`);
8
- printFindings(findings, cliOptions([]).format);
8
+ import { findConfigFile, loadConfig } from '../lib/config.js';
9
+ import { configGaps } from '../lib/config-gaps.js';
10
+ import { contractGaps } from '../lib/contract.js';
11
+ import { trackedAndUntracked } from '../lib/git.js';
12
+ import { expectedRoutes, missingRepresentatives, readPageFiles } from '../lib/routes.js';
13
+ import { toolingGaps } from '../lib/tooling.js';
14
+ import { packageVersion } from '../lib/versions.js';
15
+ import { dependabotGaps, workflowGaps } from '../lib/workflows.js';
16
+ const PAGES = 'src/pages';
17
+ function projectFiles(root) {
18
+ return {
19
+ paths: trackedAndUntracked(),
20
+ read: (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined),
21
+ };
22
+ }
23
+ function readManifest(files) {
24
+ try {
25
+ return JSON.parse(files.read('package.json') ?? '{}');
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
31
+ async function loadedConfig(root) {
32
+ if (findConfigFile(root) === undefined)
33
+ return undefined;
34
+ try {
35
+ return await loadConfig(root);
36
+ }
37
+ catch (error) {
38
+ return error instanceof Error ? error : new Error(String(error));
39
+ }
40
+ }
41
+ function orphanPatterns(root, config) {
42
+ const pages = join(root, PAGES);
43
+ if (!existsSync(pages) || config === undefined || config instanceof Error)
44
+ return [];
45
+ return missingRepresentatives(expectedRoutes(readPageFiles(pages), pages), config.routes?.representatives);
46
+ }
47
+ export async function main() {
48
+ const root = process.cwd();
49
+ const files = projectFiles(root);
50
+ const manifest = readManifest(files);
51
+ const version = packageVersion();
52
+ const config = await loadedConfig(root);
53
+ const sections = [
54
+ ['punti di aggancio dei generatori', contractGaps(root)],
55
+ ['ancoraggi delle pagine a sezioni e dei dizionari', anchorGaps(root)],
56
+ [
57
+ 'script, dipendenze e strumenti di package.json',
58
+ [...scriptGaps(manifest), ...dependencyGaps(manifest), ...toolingGaps(manifest)],
59
+ ],
60
+ ['residui di ciò che è uscito dal repository', leftoverGaps(files)],
61
+ ['preset di configurazione', presetGaps(files)],
62
+ ['workflow di GitHub e Dependabot', [...workflowGaps(files, version), ...dependabotGaps(files)]],
63
+ ['officina.config.ts', configGaps(config, orphanPatterns(root, config))],
64
+ ];
65
+ console.log(`\nofficina doctor ${version} — cosa manca al progetto per prendere tutto da fuori\n`);
66
+ const findings = [];
67
+ for (const [title, gaps] of sections) {
68
+ console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
69
+ const sectionFindings = gaps.map((gap) => ({ ...gap, severity: 'error' }));
70
+ printFindings(sectionFindings, cliOptions([]).format);
71
+ findings.push(...sectionFindings);
72
+ }
9
73
  console.log(findings.length === 0
10
- ? ' Il progetto ha tutto quello che i generatori si aspettano.\n'
11
- : `\n ${findings.length} su ${CONTRACT.length} da sistemare prima che \`officina gen\` scriva codice che compila.\n`);
74
+ ? '\n Il progetto è allineato: metodo, gate, generatori e verifiche arrivano da fuori.\n'
75
+ : `\n ${findings.length} da sistemare.\n`);
12
76
  return exitCode(findings, false);
13
77
  }
14
78
  if (import.meta.main)
15
- process.exit(main());
79
+ process.exit(await main());
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export declare function main(args?: string[]): number;
2
+ export declare function main(args?: string[]): Promise<number>;
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import process from 'node:process';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { loadConfig } from '../lib/config.js';
8
9
  import { lighthouseConfig } from '../lib/lighthouse.js';
9
10
  import { auditRoutes, expectedRoutes, readPageFiles } from '../lib/routes.js';
10
11
  const PAGES_DIR = 'src/pages';
@@ -16,22 +17,25 @@ function runLocal() {
16
17
  const env = { ...process.env, OFFICINA_BIN: process.argv[1] };
17
18
  return spawnSync('bash', [script], { stdio: 'inherit', env }).status ?? 1;
18
19
  }
19
- export function main(args = []) {
20
+ export async function main(args = []) {
20
21
  if (args.includes('--local'))
21
22
  return runLocal();
23
+ const { routes: configured = {} } = await loadConfig(process.cwd());
22
24
  const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
23
- const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR));
25
+ const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), configured.representatives);
24
26
  const resolved = lighthouseConfig(rc, routes, process.env);
25
- console.log(`\nLighthouse CI — ${routes.length} route(s) derived from ${PAGES_DIR}\n`);
27
+ console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}\n`);
26
28
  for (const url of resolved.ci.collect.url ?? [])
27
29
  console.log(` ${url}`);
28
30
  console.log();
29
31
  const dir = mkdtempSync(join(tmpdir(), 'lhci-'));
30
32
  const config = join(dir, 'lighthouserc.json');
31
33
  writeFileSync(config, JSON.stringify(resolved, null, 2));
32
- const { status } = spawnSync('pnpm', ['dlx', '@lhci/cli', 'autorun', `--config=${config}`], { stdio: 'inherit' });
34
+ const { status } = spawnSync('pnpm', ['dlx', '@lhci/cli@0.15.1', 'autorun', `--config=${config}`], {
35
+ stdio: 'inherit',
36
+ });
33
37
  rmSync(dir, { recursive: true, force: true });
34
38
  return status ?? 1;
35
39
  }
36
40
  if (import.meta.main)
37
- process.exit(main(process.argv.slice(2)));
41
+ process.exit(await main(process.argv.slice(2)));
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export declare function main(args?: string[]): number;
2
+ export declare function main(args?: string[]): Promise<number>;
@@ -3,15 +3,21 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import process from 'node:process';
4
4
  import { findingsFor } from '../lib/check-roadmap.js';
5
5
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
6
+ import { isRequired, loadConfig } from '../lib/config.js';
6
7
  const ROADMAP = 'docs/ROADMAP.md';
7
8
  // Esporta main ed esegue solo se lanciato direttamente: la copertura non segue i sottoprocessi,
8
9
  // quindi un gate provato con `node scripts/...` risulterebbe non testato.
9
- export function main(args) {
10
+ export async function main(args) {
10
11
  const options = cliOptions(args);
11
- // Un progetto appena creato dal template può non averla ancora scritta.
12
+ // Che un progetto la roadmap non ce l'abbia è una scelta, e una scelta si dichiara: il silenzio
13
+ // vale `'required'`, e un gate che esce 0 su un file che non c'è non afferma niente.
12
14
  if (!existsSync(ROADMAP)) {
13
- console.log(`\ncheck:roadmap — ${ROADMAP} non c'è ancora\n`);
14
- return 0;
15
+ if (!isRequired(await loadConfig(process.cwd()), 'roadmap')) {
16
+ console.log(`\ncheck:roadmap — spento da \`features.roadmap: false\`\n`);
17
+ return 0;
18
+ }
19
+ console.error(`\n✗ check:roadmap — ${ROADMAP} non c'è: scrivila, o dichiara \`features.roadmap: false\`\n`);
20
+ return 1;
15
21
  }
16
22
  const findings = findingsFor(readFileSync(ROADMAP, 'utf8')).map((f) => ({
17
23
  path: ROADMAP,
@@ -30,4 +36,4 @@ export function main(args) {
30
36
  return exitCode(findings, options.strict);
31
37
  }
32
38
  if (import.meta.main)
33
- process.exit(main());
39
+ process.exit(await main());
@@ -10,26 +10,26 @@ function printResults(results) {
10
10
  if (status === 'pass')
11
11
  console.log(`✓ ${check}`);
12
12
  else if (status === 'skip')
13
- console.log(`- ${check} (skipped: ${detail})`);
13
+ console.log(`- ${check} (saltato: ${detail})`);
14
14
  else
15
15
  console.error(`✗ ${check} — ${detail}`);
16
16
  }
17
17
  }
18
18
  function printFailures(failures) {
19
- console.error(`\n✗ ${failures.length} check(s) failed:`);
19
+ console.error(`\n✗ ${failures.length} controllo/i falliti:`);
20
20
  console.error(`${failures.map(({ check, detail }) => ` - ${check} — ${detail}`).join('\n')}\n`);
21
- console.error('Production is live and broken. Roll back from the Vercel dashboard:');
22
- console.error('Deployments → the last known-good production deployment → Promote to Production.\n');
21
+ console.error('La produzione è online e rotta. Torna indietro dalla dashboard di Vercel:');
22
+ console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
23
23
  }
24
24
  export async function main(args = []) {
25
- const { siteUrl, smoke = {} } = await loadConfig(process.cwd());
25
+ const { siteUrl, smoke = {}, routes = {} } = await loadConfig(process.cwd());
26
26
  const [url] = args;
27
27
  if (siteUrl === undefined) {
28
- console.error('\n✗ officina.config.ts declares no siteUrl — the canonical-host check has nothing to compare against.\n');
28
+ console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
29
29
  return 1;
30
30
  }
31
31
  if (url === undefined && new URL(siteUrl).host === 'example.com') {
32
- console.error('\n✗ siteUrl is still the template placeholder — pass a URL: pnpm smoke:prod https://…\n');
32
+ console.error('\n✗ siteUrl è ancora il segnaposto del template: passa un URL, pnpm smoke:prod https://…\n');
33
33
  return 1;
34
34
  }
35
35
  // L'apice, non l'URL *.vercel.app che stampa `vercel deploy`: lì la regola `has: host` di vercel.json mette noindex.
@@ -40,8 +40,9 @@ export async function main(args = []) {
40
40
  siteUrl,
41
41
  securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
42
42
  };
43
- const pages = smokeRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), smoke.nonHtmlRoutes);
44
- console.log(`\nProduction smoke — ${baseUrl}\n`);
43
+ const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR);
44
+ const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
45
+ console.log(`\nSmoke di produzione — ${baseUrl}\n`);
45
46
  await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
46
47
  const results = await runChecks(context, pages, smoke.checks);
47
48
  printResults(results);
@@ -50,7 +51,7 @@ export async function main(args = []) {
50
51
  printFailures(failures);
51
52
  return 1;
52
53
  }
53
- console.log(`\n✓ Every check passed on ${baseUrl}.\n`);
54
+ console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
54
55
  return 0;
55
56
  }
56
57
  if (import.meta.main)
@@ -1,29 +1,45 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import process from 'node:process';
4
4
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
5
- import { pinFindings } from '../lib/vercel-cli-pin.js';
6
- const WORKFLOW = '.github/workflows/deploy.yml';
5
+ import { pinFindings } from '../lib/pins.js';
6
+ // I pin di ciò che si scarica al volo stanno nei file di questo repository: un progetto prende
7
+ // `vercel` dall'action di deploy e `@lhci/cli` dal gate, e non ne pinna nessuno per conto suo.
8
+ const PINNED = [
9
+ { package: 'vercel', files: ['actions/deploy/action.yml'] },
10
+ { package: '@lhci/cli', files: ['src/checks/lighthouse.ts'] },
11
+ ];
7
12
  // Il documento `latest` porta la sola versione pubblicata; quello del pacchetto intero pesa 8 MB.
8
- const REGISTRY = 'https://registry.npmjs.org/vercel/latest';
13
+ const REGISTRY = 'https://registry.npmjs.org';
9
14
  const TIMEOUT_MS = 15_000;
15
+ const read = (path) => ({ path, source: existsSync(path) ? readFileSync(path, 'utf8') : '' });
16
+ async function latestOf(pkg) {
17
+ const response = await fetch(`${REGISTRY}/${pkg.replace('/', '%2f')}/latest`, {
18
+ signal: AbortSignal.timeout(TIMEOUT_MS),
19
+ });
20
+ if (!response.ok)
21
+ return undefined;
22
+ const { version } = (await response.json());
23
+ return version;
24
+ }
10
25
  export async function main() {
11
- const response = await fetch(REGISTRY, { signal: AbortSignal.timeout(TIMEOUT_MS) });
12
- if (!response.ok) {
13
- console.error(`✗ registry npm illeggibile: HTTP ${response.status}`);
14
- return 1;
26
+ const findings = [];
27
+ const published = [];
28
+ for (const { package: pkg, files } of PINNED) {
29
+ const latest = await latestOf(pkg);
30
+ if (latest === undefined) {
31
+ findings.push({ path: files[0], message: `registry npm illeggibile per \`${pkg}\``, severity: 'error' });
32
+ continue;
33
+ }
34
+ published.push(`${pkg}@${latest}`);
35
+ const sites = [read(files[0]), ...files.slice(1).map(read)];
36
+ findings.push(...pinFindings(pkg, latest, sites).map((hit) => ({ ...hit, severity: 'error' })));
15
37
  }
16
- const { version } = (await response.json());
17
- const findings = pinFindings(readFileSync(WORKFLOW, 'utf8'), version).map((hit) => ({
18
- path: WORKFLOW,
19
- ...hit,
20
- severity: 'error',
21
- }));
22
- console.log(`\ncheck:vercel-cli — il pin contro la ${version} pubblicata su npm\n`);
38
+ console.log(`\ncheck:vercel-cli — i pin contro quello che npm pubblica (${published.join(', ')})\n`);
23
39
  printFindings(findings, cliOptions([]).format);
24
40
  console.log(findings.length === 0
25
- ? ' Il pin è sulla major pubblicata.\n'
26
- : `\n Alza il pin in ${WORKFLOW} e allinea la CLI locale alla stessa major.\n`);
41
+ ? ' Ogni pin è esatto e sulla major pubblicata.\n'
42
+ : '\n Alza i pin e allinea la CLI locale alla stessa versione.\n');
27
43
  return exitCode(findings, false);
28
44
  }
29
45
  if (import.meta.main)
@@ -0,0 +1,6 @@
1
+ export declare class HookPointError extends Error {
2
+ path: any;
3
+ problem: any;
4
+ constructor(message: any, path: any, problem: any);
5
+ }
6
+ export declare const failing: (prefix: any, guide: any) => (path: any, problem: any) => never;
@@ -0,0 +1,13 @@
1
+ // Un ancoraggio rotto ha un percorso e un problema. Il pre-volo li impagina nel messaggio che
2
+ // ferma il generatore; `doctor`, che gli stessi controlli li lancia senza generare niente, li
3
+ // rimette in un ritrovamento.
4
+ export class HookPointError extends Error {
5
+ constructor(message, path, problem) {
6
+ super(message);
7
+ this.path = path;
8
+ this.problem = problem;
9
+ }
10
+ }
11
+ export const failing = (prefix, guide) => (path, problem) => {
12
+ throw new HookPointError(`${prefix} in ${path}: ${problem} (contract: ${guide})`, path, problem);
13
+ };