@elia97/officina 0.4.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 (56) hide show
  1. package/README.md +57 -15
  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 +10 -4
  6. package/dist/checks/bundle.js +10 -2
  7. package/dist/checks/doctor.js +33 -6
  8. package/dist/checks/lighthouse.d.ts +1 -1
  9. package/dist/checks/lighthouse.js +8 -4
  10. package/dist/checks/roadmap.d.ts +1 -1
  11. package/dist/checks/roadmap.js +11 -5
  12. package/dist/checks/smoke.js +3 -2
  13. package/dist/checks/vercel-cli.d.ts +1 -1
  14. package/dist/checks/vercel-cli.js +35 -21
  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 +2 -6
  28. package/dist/lib/alignment.js +25 -52
  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 +16 -1
  36. package/dist/lib/config.js +36 -4
  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 +16 -7
  43. package/dist/lib/tooling.d.ts +3 -0
  44. package/dist/lib/tooling.js +24 -0
  45. package/dist/lib/validate.d.ts +11 -0
  46. package/dist/lib/validate.js +40 -0
  47. package/dist/lib/versions.d.ts +5 -0
  48. package/dist/lib/versions.js +38 -0
  49. package/dist/lib/workflows.d.ts +6 -0
  50. package/dist/lib/workflows.js +70 -0
  51. package/dist/sh/lhci-local.sh +2 -2
  52. package/dist/smoke.d.ts +2 -0
  53. package/dist/smoke.js +4 -0
  54. package/package.json +28 -12
  55. package/dist/lib/vercel-cli-pin.d.ts +0 -2
  56. package/dist/lib/vercel-cli-pin.js +0 -29
package/README.md CHANGED
@@ -49,7 +49,8 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
49
49
  Il motore è uguale per tutti; ciò che cambia da un progetto all'altro sta in un file solo, `officina.config.ts` nella radice:
50
50
 
51
51
  ```ts
52
- 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'
53
54
 
54
55
  import { SITE } from './src/lib/site.ts'
55
56
 
@@ -61,12 +62,26 @@ export default defineConfig({
61
62
  icons: { background: SITE.themeColor.light },
62
63
  bundle: { budgets: [motion], cssMaxGzip: 14 * 1024 },
63
64
  smoke: { checks: [...DEFAULT_CHECKS, checkLanguageRedirect] },
65
+ features: { analytics: 'required', roadmap: false },
66
+ routes: { representatives: { '/blog/[slug]': '/blog/ciao-mondo' } },
64
67
  })
65
68
  ```
66
69
 
67
- 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.
68
71
 
69
- `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.
70
85
 
71
86
  ## Preset di configurazione
72
87
 
@@ -87,13 +102,13 @@ extends:
87
102
 
88
103
  ## Action per i workflow
89
104
 
90
- 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, l'`environment` e i segreti restano del progetto. Il riferimento va fissato a un tag di versione o a uno SHA, mai a `@main`.
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.
91
106
 
92
107
  | Action | Passi | Cosa resta nel workflow del progetto |
93
108
  |---|---|---|
94
109
  | `actions/ci` | checkout, node, install, `pnpm run ci`, build, `perf:bundle`; con `e2e: 'true'` anche Playwright | trigger, permessi, il job `ci` |
95
110
  | `actions/review` | `fallow review` sul diff contro il merge-base | il job informativo |
96
- | `actions/deploy` | risoluzione del tag, gate, `vercel pull`, `build`, `deploy`, smoke; espone `url` | trigger, `environment`, i tre segreti Vercel nell'`env` del job, il job che controlla se i segreti ci sono |
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 |
97
112
  | `actions/lighthouse` | build equivalente alla produzione e `pnpm run lhci` | trigger, etichetta, `continue-on-error` |
98
113
 
99
114
  ```yaml
@@ -103,22 +118,40 @@ jobs:
103
118
  environment:
104
119
  name: production
105
120
  url: ${{ steps.deploy.outputs.url }}
106
- env:
107
- VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
108
- VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
109
- VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
110
121
  steps:
111
122
  - uses: Elia97/officina/actions/deploy@v0.4.0
112
123
  id: deploy
113
124
  with:
114
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 }}
115
129
  ```
116
130
 
117
- Il pin `vercel@<major>` sta in un posto solo, `actions/deploy/action.yml`: lo guarda ogni lunedì `.github/workflows/vercel-cli.yml` di questo repository, con `officina check vercel-cli actions/deploy/action.yml`.
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.
118
136
 
119
137
  ## doctor
120
138
 
121
- `officina doctor` dice, in qualunque repository, cosa manca perché il progetto prenda tutto da fuori: i punti di aggancio dei generatori, gli script e le dipendenze di `package.json`, i residui di ciò che è uscito (copia del metodo, script, documenti commerciali), i preset, i workflow con il riferimento fissato, `officina.config.ts`. Sta dentro `ci`, quindi un progetto allineato non torna indietro senza che il gate lo dica.
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.
122
155
 
123
156
  ## Sviluppo
124
157
 
@@ -127,24 +160,33 @@ pnpm install
127
160
  pnpm test # vitest; i test che leggono dal disco girano in test/fixture-project/
128
161
  pnpm typecheck
129
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
130
164
  ```
131
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
+
132
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`.
133
169
 
134
170
  ## Pubblicare
135
171
 
136
172
  ```sh
137
- pnpm version <patch|minor|major>
138
- 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
139
175
  ```
140
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
+
141
183
  ## Generatori
142
184
 
143
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.
144
186
 
145
- 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`, ed è la prima sezione di `officina doctor`. 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.
146
188
 
147
- 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.
148
190
 
149
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.
150
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;
@@ -25,11 +25,17 @@ function reportCoverage(coverage) {
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('Nessun container GTM da controllare: passane uno come argomento o imposta 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),
@@ -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)
@@ -78,7 +86,7 @@ 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));
89
+ failures.push(...unknownChunkFailures(pages), ...overBudgetFailures(pages));
82
90
  const width = Math.max('ROTTA'.length, ...pages.map((p) => p.route.length));
83
91
  printPages(pages, width);
84
92
  const stylesheets = readStylesheets();
@@ -2,17 +2,32 @@
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import process from 'node:process';
5
- import { configGaps, dependencyGaps, leftoverGaps, presetGaps, scriptGaps, workflowGaps, } from '../lib/alignment.js';
5
+ import { dependencyGaps, leftoverGaps, presetGaps, scriptGaps, } from '../lib/alignment.js';
6
+ import { anchorGaps } from '../lib/anchors.js';
6
7
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
7
8
  import { findConfigFile, loadConfig } from '../lib/config.js';
9
+ import { configGaps } from '../lib/config-gaps.js';
8
10
  import { contractGaps } from '../lib/contract.js';
9
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';
10
17
  function projectFiles(root) {
11
18
  return {
12
19
  paths: trackedAndUntracked(),
13
20
  read: (path) => (existsSync(join(root, path)) ? readFileSync(join(root, path), 'utf8') : undefined),
14
21
  };
15
22
  }
23
+ function readManifest(files) {
24
+ try {
25
+ return JSON.parse(files.read('package.json') ?? '{}');
26
+ }
27
+ catch {
28
+ return {};
29
+ }
30
+ }
16
31
  async function loadedConfig(root) {
17
32
  if (findConfigFile(root) === undefined)
18
33
  return undefined;
@@ -23,19 +38,31 @@ async function loadedConfig(root) {
23
38
  return error instanceof Error ? error : new Error(String(error));
24
39
  }
25
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
+ }
26
47
  export async function main() {
27
48
  const root = process.cwd();
28
49
  const files = projectFiles(root);
29
- const manifest = JSON.parse(files.read('package.json') ?? '{}');
50
+ const manifest = readManifest(files);
51
+ const version = packageVersion();
52
+ const config = await loadedConfig(root);
30
53
  const sections = [
31
54
  ['punti di aggancio dei generatori', contractGaps(root)],
32
- ['script e dipendenze di package.json', [...scriptGaps(manifest), ...dependencyGaps(manifest)]],
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
+ ],
33
60
  ['residui di ciò che è uscito dal repository', leftoverGaps(files)],
34
61
  ['preset di configurazione', presetGaps(files)],
35
- ['workflow di GitHub', workflowGaps(files)],
36
- ['officina.config.ts', configGaps(await loadedConfig(root))],
62
+ ['workflow di GitHub e Dependabot', [...workflowGaps(files, version), ...dependabotGaps(files)]],
63
+ ['officina.config.ts', configGaps(config, orphanPatterns(root, config))],
37
64
  ];
38
- console.log('\nofficina doctor — cosa manca al progetto per prendere tutto da fuori\n');
65
+ console.log(`\nofficina doctor ${version} — cosa manca al progetto per prendere tutto da fuori\n`);
39
66
  const findings = [];
40
67
  for (const [title, gaps] of sections) {
41
68
  console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
@@ -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,11 +17,12 @@ 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
27
  console.log(`\nLighthouse CI — ${routes.length} rotta/e derivate da ${PAGES_DIR}\n`);
26
28
  for (const url of resolved.ci.collect.url ?? [])
@@ -29,9 +31,11 @@ export function main(args = []) {
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());
@@ -22,7 +22,7 @@ function printFailures(failures) {
22
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
28
  console.error('\n✗ officina.config.ts non dichiara siteUrl: il controllo dell’host canonico non ha con cosa confrontarsi.\n');
@@ -40,7 +40,8 @@ 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);
43
+ const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR);
44
+ const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
44
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);
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- export declare function main(args?: string[]): Promise<number>;
2
+ export declare function main(): Promise<number>;
@@ -1,32 +1,46 @@
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 DEFAULT_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;
10
- // Il file è un argomento perché officina controlla il pin della propria action di deploy.
11
- export async function main(args = []) {
12
- const workflow = args[0] ?? DEFAULT_WORKFLOW;
13
- const response = await fetch(REGISTRY, { signal: AbortSignal.timeout(TIMEOUT_MS) });
14
- if (!response.ok) {
15
- console.error(`✗ registry npm illeggibile: HTTP ${response.status}`);
16
- return 1;
17
- }
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;
18
22
  const { version } = (await response.json());
19
- const findings = pinFindings(readFileSync(workflow, 'utf8'), version).map((hit) => ({
20
- path: workflow,
21
- ...hit,
22
- severity: 'error',
23
- }));
24
- console.log(`\ncheck:vercel-cli — il pin contro la ${version} pubblicata su npm\n`);
23
+ return version;
24
+ }
25
+ export async function main() {
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' })));
37
+ }
38
+ console.log(`\ncheck:vercel-cli — i pin contro quello che npm pubblica (${published.join(', ')})\n`);
25
39
  printFindings(findings, cliOptions([]).format);
26
40
  console.log(findings.length === 0
27
- ? ' Il pin è sulla major pubblicata.\n'
28
- : `\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');
29
43
  return exitCode(findings, false);
30
44
  }
31
45
  if (import.meta.main)
32
- process.exit(await main(process.argv.slice(2)));
46
+ process.exit(await 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
+ };
@@ -1,7 +1,30 @@
1
- export declare function assertSectionInjectable({ root, collection, camel, kebab, pascal, image }: {
1
+ import { Project } from 'ts-morph';
2
+ export declare function assertSectionAnchors({ root, collection, project }: {
3
+ collection: any;
4
+ project?: Project | undefined;
5
+ root: any;
6
+ }): {
7
+ targets: {
8
+ barrel: string;
9
+ schemaFunction: string;
10
+ dataLayer: string;
11
+ dataFunction: string;
12
+ schemaDir: string;
13
+ contentDir: string;
14
+ componentDir: string;
15
+ importsMarker: string;
16
+ sectionsMarker: string;
17
+ };
18
+ barrel: any;
19
+ fn: any;
20
+ union: any;
21
+ obj: any;
22
+ page: string | undefined;
23
+ src: string;
24
+ };
25
+ export declare function assertSectionInjectable({ root, collection, camel, kebab, pascal }: {
2
26
  camel: any;
3
27
  collection: any;
4
- image?: boolean | undefined;
5
28
  kebab: any;
6
29
  pascal: any;
7
30
  root: any;
@@ -2,12 +2,11 @@
2
2
  // trivia iniziale del nuovo import e lo sposta nel blocco ordinato. (.astro non è leggibile da ts-morph.)
3
3
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { Node, Project, SyntaxKind } from 'ts-morph';
5
+ import { failing } from './hook-points.mjs';
5
6
  import { findSectionedPages, sectionFiles, sectionTargets } from './section-targets.mjs';
6
7
  import { isNameTaken } from './ts-morph-utils.mjs';
7
8
  const GUIDE = 'docs/guides/content-collections.md';
8
- function fail(where, problem) {
9
- throw new Error(`gen:section injection failed in ${where}: ${problem} (contract: ${GUIDE})`);
10
- }
9
+ const fail = failing('gen:section injection failed', GUIDE);
11
10
  function addSource(project, root, path, missing) {
12
11
  if (!existsSync(`${root}/${path}`))
13
12
  fail(path, missing);
@@ -71,23 +70,27 @@ function assertContextForwardable(fn, targets) {
71
70
  fail(targets.barrel, `${targets.schemaFunction} destructures its parameter — name it \`context: SchemaContext\`, so an image section can receive it`);
72
71
  }
73
72
  }
74
- export function assertSectionInjectable({ root, collection, camel, kebab, pascal, image = false }) {
73
+ // Gli ancoraggi di una collection a sezioni, senza sapere che sezione stia per nascere: il pre-volo
74
+ // ci aggiunge i controlli sul nome nuovo, `doctor` li guarda e basta.
75
+ export function assertSectionAnchors({ root, collection, project = new Project() }) {
75
76
  const targets = sectionTargets(collection);
76
- const project = new Project();
77
77
  const { barrel, fn, union } = locateUnionArray(project, root, targets, collection.kebab);
78
+ assertContextForwardable(fn, targets);
79
+ const obj = locateReturnObject(project, root, targets, collection.kebab);
80
+ const { page, src } = readSectionedPage(root, targets);
81
+ return { targets, barrel, fn, union, obj, page, src };
82
+ }
83
+ export function assertSectionInjectable({ root, collection, camel, kebab, pascal }) {
84
+ const { targets, barrel, union, obj, page, src } = assertSectionAnchors({ root, collection });
78
85
  if (inUnion(union, camel)) {
79
86
  fail(targets.barrel, `section "${camel}" is already in the union — pick another name`);
80
87
  }
81
- if (image)
82
- assertContextForwardable(fn, targets);
83
88
  if (isNameTaken(barrel, `${camel}SectionSchema`)) {
84
89
  fail(targets.barrel, `the identifier \`${camel}SectionSchema\` is already taken — the injected import would collide. Pick another name`);
85
90
  }
86
- const obj = locateReturnObject(project, root, targets, collection.kebab);
87
91
  if (obj.getProperty(camel)) {
88
92
  fail(targets.dataLayer, `section "${camel}" is already picked in ${targets.dataFunction}`);
89
93
  }
90
- const { page, src } = readSectionedPage(root, targets);
91
94
  const frontmatter = src.split('---')[1] ?? '';
92
95
  if (new RegExp(`\\b${pascal}\\b`).test(frontmatter)) {
93
96
  fail(page, `the identifier \`${pascal}\` is already used in the frontmatter — the component import would collide. Pick another name`);
@@ -109,9 +112,8 @@ function contextArgument(barrel, fn) {
109
112
  return 'context';
110
113
  }
111
114
  export function injectSection({ root, collection, camel, kebab, pascal, image = false }) {
112
- const targets = sectionTargets(collection);
113
115
  const project = new Project();
114
- const { barrel, fn, union } = locateUnionArray(project, root, targets, collection.kebab);
116
+ const { targets, barrel, fn, union, obj, page, src: original } = assertSectionAnchors({ root, collection, project });
115
117
  if (!barrel.getImportDeclaration((d) => d.getModuleSpecifierValue() === `./${kebab}`)) {
116
118
  barrel.addImportDeclaration({
117
119
  moduleSpecifier: `./${kebab}`,
@@ -121,12 +123,10 @@ export function injectSection({ root, collection, camel, kebab, pascal, image =
121
123
  if (!inUnion(union, camel)) {
122
124
  union.addElement(`${camel}SectionSchema(${image ? contextArgument(barrel, fn) : ''})`);
123
125
  }
124
- const obj = locateReturnObject(project, root, targets, collection.kebab);
125
126
  if (!obj.getProperty(camel)) {
126
127
  obj.addPropertyAssignment({ name: camel, initializer: `pick('${camel}')` });
127
128
  }
128
129
  project.saveSync();
129
- const { page, src: original } = readSectionedPage(root, targets);
130
130
  let src = original;
131
131
  const imp = `import ${pascal} from '@/components/${collection.kebab}/${kebab}.astro'`;
132
132
  if (!src.includes(imp)) {
@@ -1,3 +1,6 @@
1
+ export declare function assertDictionaries({ root }: {
2
+ root: any;
3
+ }): void;
1
4
  export declare function assertStringsInjectable({ root, keys }: {
2
5
  keys: any;
3
6
  root: any;
@@ -1,11 +1,10 @@
1
1
  import { readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { Project, SyntaxKind } from 'ts-morph';
4
+ import { failing } from './hook-points.mjs';
4
5
  const DICTIONARIES = 'src/i18n/strings';
5
6
  const GUIDE = 'docs/guides/content-collections.md';
6
- function fail(where, problem) {
7
- throw new Error(`gen:page injection failed in ${where}: ${problem} (contract: ${GUIDE})`);
8
- }
7
+ const fail = failing('gen:page injection failed', GUIDE);
9
8
  function locateObject(file, path, locale) {
10
9
  const declaration = file.getVariableDeclaration(locale);
11
10
  if (!declaration) {
@@ -37,6 +36,11 @@ function loadDictionaries(root) {
37
36
  });
38
37
  return { project, dictionaries };
39
38
  }
39
+ // La forma dei dizionari, senza sapere quali chiavi stiano per arrivare: il pre-volo ci aggiunge
40
+ // il controllo sulle chiavi nuove, `doctor` la guarda e basta.
41
+ export function assertDictionaries({ root }) {
42
+ loadDictionaries(root);
43
+ }
40
44
  export function assertStringsInjectable({ root, keys }) {
41
45
  for (const { path, object } of loadDictionaries(root).dictionaries) {
42
46
  for (const key of keys) {