@elia97/officina 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ I gate leggono dalla cartella corrente, che deve essere la radice del repository
44
44
  | `check smoke [url]` | la produzione viva: pagine, header di sicurezza, BotID, host canonico, barra finale | `src/pages`, più le rotte non HTML |
45
45
  | `check analytics [GTM-…]` | il container GTM pubblico contro gli eventi del modulo di link-tracking del progetto (`analytics.linkTracking`) | — |
46
46
  | `check lighthouse [--local]` | Lighthouse CI sul `.lighthouserc.json` del progetto; `--local` fa build, server e Chrome da sé | `src/pages` |
47
- | `gen icons` | le icone del manifest, disegnate dal favicon SVG in `public/` del progetto | — |
47
+ | `gen icons` | le icone del manifest, disegnate da `public/favicon.svg`, che è il suo ingresso obbligatorio | — |
48
48
 
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
 
@@ -79,11 +79,13 @@ La radice del pacchetto esporta `defineConfig` e i tipi; i pezzi del motore stan
79
79
 
80
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
81
 
82
+ `features.generators` ha un valore in più delle altre due, perché non tutti i siti in produzione nascono dallo scaffold: `'required'` è il comportamento di sempre; `false` spegne i generatori del pacchetto, e `doctor` smette di chiedere i punti di aggancio e gli ancoraggi invece di pretendere file che nessun codice di quel sito userebbe; `'project'` fa lo stesso ma lascia vivi i generatori che il progetto scrive in `officina.generators.mjs`, che a quel punto `doctor` pretende. Con `false` o `'project'`, `officina gen section|page|component|collection` si ferma dicendolo, mentre gli script `gen` e `gen:*` restano attesi in `package.json`: meglio un comando che spiega di uno script che manca. `gen icons` non c'entra con i generatori di codice e non cambia.
83
+
82
84
  `placeholders` dice a `check placeholders` cosa guardare: `sources` sono i file da scandire, `contactEnvKeys` le variabili che `--env` pretende. Una lista dichiarata **sostituisce** quella del pacchetto, dizionari compresi: senza dichiarazione i sorgenti sono `src/lib/site.ts`, `src/lib/company.ts` e i dizionari di `src/i18n/strings`, quando la cartella esiste. Serve ai progetti che non nascono dallo scaffold: se partita IVA, recapiti e destinatario dei form stanno nel contenuto invece che in un modulo, i sorgenti del template non sono i suoi, e crearli per far passare il gate terrebbe gli stessi dati in due sedi. Un sorgente elencato che non esiste è un ritrovamento con il suo percorso, non un errore che ferma il comando.
83
85
 
84
86
  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.
85
87
 
86
- `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.
88
+ `sharp` è una dipendenza facoltativa del progetto, non del pacchetto: serve solo a `gen icons`, che senza lo dice ed esce 1. Un ingresso che manca — il favicon, `.lighthouserc.json`, `src/pages`, la build in `dist/client` — è sempre una riga che nomina il file e dice a cosa serve, mai uno stack trace. `@biomejs/biome` invece è una peer dependency con l'intervallo che il preset regge, e `doctor` confronta la versione del progetto con quello.
87
89
 
88
90
  ## Preset di configurazione
89
91
 
@@ -140,6 +142,8 @@ Perché le action non restino ferme per sempre, Dependabot di questo repository
140
142
 
141
143
  `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.
142
144
 
145
+ Di `.github/dependabot.yml` guarda la **forma**, non la presenza delle chiavi: lo legge come YAML e pretende che le chiavi del gruppo stiano sul gruppo — Dependabot rifiuta l'intero file se `commit-message` o `open-pull-requests-limit` stanno su una voce che dichiara `multi-ecosystem-group` — e che ogni ecosistema del gruppo abbia una seconda voce per il resto. Senza quella, `patterns` restringe l'intera voce e tutto il resto smette di aggiornarsi senza che niente diventi rosso.
146
+
143
147
  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:
144
148
 
145
149
  - 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`;
@@ -4,6 +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 { missingInput } from '../lib/cli.js';
7
8
  import { isRequired, loadConfig } from '../lib/config.js';
8
9
  import { extractTriggers, googleTagIds, parseContainerData } from '../lib/gtm-container.js';
9
10
  const LINK_TRACKING = 'src/lib/analytics/link-tracking.ts';
@@ -37,6 +38,9 @@ export async function main(args = []) {
37
38
  ' oppure dichiara `features.analytics: false` se il progetto non ha analytics.\n');
38
39
  return 1;
39
40
  }
41
+ const linkTracking = analytics.linkTracking ?? LINK_TRACKING;
42
+ if (missingInput(linkTracking, 'è il modulo da cui si leggono gli eventi; `analytics.linkTracking` dice dove sta'))
43
+ return 1;
40
44
  const response = await fetch(`https://www.googletagmanager.com/gtm.js?id=${gtmId}`, {
41
45
  signal: AbortSignal.timeout(TIMEOUT_MS),
42
46
  });
@@ -45,7 +49,7 @@ export async function main(args = []) {
45
49
  return 1;
46
50
  }
47
51
  const container = parseContainerData(await response.text());
48
- const source = readFileSync(analytics.linkTracking ?? LINK_TRACKING, 'utf8');
52
+ const source = readFileSync(linkTracking, 'utf8');
49
53
  const coverage = coverageOf(extractLinkEvents(source), extractTriggers(container));
50
54
  console.log(`\nGTM ${gtmId} — ID di misurazione: ${googleTagIds(container).join(', ') || '—'}\n`);
51
55
  return reportCoverage(coverage);
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import process from 'node:process';
6
6
  import { gzipSync } from 'node:zlib';
7
7
  import { budgetFor, CSS_BUDGET_GZIP, cssBudgetFailure, deferredClosure, heaviestStylesheet, htmlEntries, parseEdges, staticClosure, } from '../lib/bundle-budget.js';
8
+ import { missingInput } from '../lib/cli.js';
8
9
  import { loadConfig } from '../lib/config.js';
9
10
  import { expectedRoutes, filesWithExtension, missingRouteFailures, readPageFiles, routeOf, } from '../lib/routes.js';
10
11
  const DIST = 'dist/client';
@@ -82,6 +83,10 @@ function printSsrNote(expected) {
82
83
  }
83
84
  export async function main() {
84
85
  const { bundle = {} } = await loadConfig(process.cwd());
86
+ if (missingInput(ASSETS, 'è la build da misurare: prima `pnpm build`'))
87
+ return 1;
88
+ if (missingInput(PAGES, 'le rotte attese si derivano da lì'))
89
+ return 1;
85
90
  const cssMaxGzip = bundle.cssMaxGzip ?? CSS_BUDGET_GZIP;
86
91
  const pages = measurePages(readChunks(), bundle.budgets ?? []);
87
92
  const expected = expectedRoutes(readPageFiles(PAGES), PAGES);
@@ -2,17 +2,19 @@
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import process from 'node:process';
5
+ import { PROJECT_GENERATORS } from '../gen/plopfile.mjs';
5
6
  import { dependencyGaps, leftoverGaps, presetGaps, scriptGaps, } from '../lib/alignment.js';
6
7
  import { anchorGaps } from '../lib/anchors.js';
7
8
  import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
8
- import { findConfigFile, loadConfig } from '../lib/config.js';
9
+ import { findConfigFile, generatorsSetting, loadConfig, } from '../lib/config.js';
9
10
  import { configGaps } from '../lib/config-gaps.js';
10
11
  import { contractGaps } from '../lib/contract.js';
12
+ import { dependabotGaps } from '../lib/dependabot.js';
11
13
  import { trackedAndUntracked } from '../lib/git.js';
12
14
  import { expectedRoutes, missingRepresentatives, readPageFiles } from '../lib/routes.js';
13
15
  import { toolingGaps } from '../lib/tooling.js';
14
16
  import { packageVersion } from '../lib/versions.js';
15
- import { dependabotGaps, workflowGaps } from '../lib/workflows.js';
17
+ import { workflowGaps } from '../lib/workflows.js';
16
18
  const PAGES = 'src/pages';
17
19
  function projectFiles(root) {
18
20
  return {
@@ -38,6 +40,30 @@ async function loadedConfig(root) {
38
40
  return error instanceof Error ? error : new Error(String(error));
39
41
  }
40
42
  }
43
+ const HOOK_POINTS = 'punti di aggancio dei generatori';
44
+ const ANCHORS = 'ancoraggi delle pagine a sezioni e dei dizionari';
45
+ const usable = (config) => config === undefined || config instanceof Error ? {} : config;
46
+ function generatorSections(root, setting) {
47
+ if (setting === 'required') {
48
+ return [
49
+ { title: HOOK_POINTS, gaps: contractGaps(root) },
50
+ { title: ANCHORS, gaps: anchorGaps(root) },
51
+ ];
52
+ }
53
+ const off = `\`features.generators: ${setting === false ? 'false' : "'project'"}\``;
54
+ if (setting === false)
55
+ return [
56
+ { title: HOOK_POINTS, off },
57
+ { title: ANCHORS, off },
58
+ ];
59
+ const missing = existsSync(join(root, PROJECT_GENERATORS))
60
+ ? []
61
+ : [{ path: PROJECT_GENERATORS, message: "manca: con `generators: 'project'` i generatori sono i suoi" }];
62
+ return [
63
+ { title: HOOK_POINTS, gaps: missing },
64
+ { title: ANCHORS, off },
65
+ ];
66
+ }
41
67
  function orphanPatterns(root, config) {
42
68
  const pages = join(root, PAGES);
43
69
  if (!existsSync(pages) || config === undefined || config instanceof Error)
@@ -51,20 +77,27 @@ export async function main() {
51
77
  const version = packageVersion();
52
78
  const config = await loadedConfig(root);
53
79
  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))],
80
+ ...generatorSections(root, generatorsSetting(usable(config))),
81
+ {
82
+ title: 'script, dipendenze e strumenti di package.json',
83
+ gaps: [...scriptGaps(manifest), ...dependencyGaps(manifest), ...toolingGaps(manifest)],
84
+ },
85
+ { title: 'residui di ciò che è uscito dal repository', gaps: leftoverGaps(files) },
86
+ { title: 'preset di configurazione', gaps: presetGaps(files) },
87
+ {
88
+ title: 'workflow di GitHub e Dependabot',
89
+ gaps: [...workflowGaps(files, version), ...dependabotGaps(files)],
90
+ },
91
+ { title: 'officina.config.ts', gaps: configGaps(config, orphanPatterns(root, config)) },
64
92
  ];
65
93
  console.log(`\nofficina doctor ${version} — cosa manca al progetto per prendere tutto da fuori\n`);
66
94
  const findings = [];
67
- for (const [title, gaps] of sections) {
95
+ for (const section of sections) {
96
+ if ('off' in section) {
97
+ console.log(` · ${section.title} — spento da ${section.off}`);
98
+ continue;
99
+ }
100
+ const { title, gaps } = section;
68
101
  console.log(gaps.length === 0 ? ` ✓ ${title}` : ` ${title}: ${gaps.length}`);
69
102
  const sectionFindings = gaps.map((gap) => ({ ...gap, severity: 'error' }));
70
103
  printFindings(sectionFindings, cliOptions([]).format);
@@ -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 { missingInput } from '../lib/cli.js';
8
9
  import { loadConfig } from '../lib/config.js';
9
10
  import { lighthouseConfig } from '../lib/lighthouse.js';
10
11
  import { auditRoutes, expectedRoutes, readPageFiles } from '../lib/routes.js';
@@ -21,6 +22,10 @@ export async function main(args = []) {
21
22
  if (args.includes('--local'))
22
23
  return runLocal();
23
24
  const { routes: configured = {} } = await loadConfig(process.cwd());
25
+ if (missingInput(RC_FILE, 'è la configurazione di Lighthouse CI'))
26
+ return 1;
27
+ if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
28
+ return 1;
24
29
  const rc = JSON.parse(readFileSync(RC_FILE, 'utf8'));
25
30
  const routes = auditRoutes(expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR), configured.representatives);
26
31
  const resolved = lighthouseConfig(rc, routes, process.env);
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // L'unico controllo che vede cosa serve davvero il bordo: i test del progetto fissano solo vercel.json.
3
3
  import process from 'node:process';
4
+ import { missingInput } from '../lib/cli.js';
4
5
  import { loadConfig } from '../lib/config.js';
5
6
  import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
6
7
  import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
@@ -40,6 +41,8 @@ export async function main(args = []) {
40
41
  siteUrl,
41
42
  securityHeaders: smoke.securityHeaders ?? SECURITY_HEADERS,
42
43
  };
44
+ if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
45
+ return 1;
43
46
  const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR);
44
47
  const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
45
48
  console.log(`\nSmoke di produzione — ${baseUrl}\n`);
package/dist/gen/icons.js CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process';
3
+ import { missingInput } from '../lib/cli.js';
3
4
  import { loadConfig } from '../lib/config.js';
5
+ const FAVICON = 'public/favicon.svg';
4
6
  const isMissingSharp = (error) => error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND' && error.message.includes('sharp');
5
7
  export async function main() {
6
8
  const { icons } = await loadConfig(process.cwd());
@@ -8,6 +10,8 @@ export async function main() {
8
10
  console.error('\n✗ officina.config.ts non dichiara icons.background: è il colore di fondo delle icone.\n');
9
11
  return 1;
10
12
  }
13
+ if (missingInput(FAVICON, 'è il disegno da cui officina ricava i PNG del manifest'))
14
+ return 1;
11
15
  try {
12
16
  // Caricato qui e non in testa: sharp è del progetto, e chi non genera icone non lo deve avere.
13
17
  const { writeIcons } = await import('../lib/icons.js');
@@ -18,10 +18,12 @@ import sectionGenerator from './section.mjs';
18
18
  // pacchetto: stessa firma di un plopfile, `export default function (plop)`.
19
19
  export const PROJECT_GENERATORS = 'officina.generators.mjs';
20
20
  export default async function (plop) {
21
- sectionGenerator(plop);
22
- pageGenerator(plop);
23
- componentGenerator(plop);
24
- collectionGenerator(plop);
21
+ if (process.env.OFFICINA_GENERATORS !== 'project') {
22
+ sectionGenerator(plop);
23
+ pageGenerator(plop);
24
+ componentGenerator(plop);
25
+ collectionGenerator(plop);
26
+ }
25
27
  const extension = join(process.cwd(), PROJECT_GENERATORS);
26
28
  if (!existsSync(extension))
27
29
  return;
package/dist/gen/run.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export declare function plopCommand(args: string[], cwd: string): string[];
2
- export declare function main(args?: string[]): number;
2
+ export declare function main(args?: string[]): Promise<number>;
package/dist/gen/run.js CHANGED
@@ -3,6 +3,9 @@ import { createRequire } from 'node:module';
3
3
  import { dirname, join } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { missingInput } from '../lib/cli.js';
7
+ import { generatorsSetting, loadConfig } from '../lib/config.js';
8
+ import { PROJECT_GENERATORS } from './plopfile.mjs';
6
9
  // plop risolve i template dalla cartella del plopfile e scrive in `--dest`: il plopfile sta nel
7
10
  // pacchetto, la destinazione è il progetto da cui si lancia il comando.
8
11
  export function plopCommand(args, cwd) {
@@ -11,7 +14,16 @@ export function plopCommand(args, cwd) {
11
14
  const plopfile = fileURLToPath(new URL('./plopfile.mjs', import.meta.url));
12
15
  return [plopBin, '--plopfile', plopfile, '--dest', cwd, ...args];
13
16
  }
14
- export function main(args = []) {
15
- const result = spawnSync(process.execPath, plopCommand(args, process.cwd()), { stdio: 'inherit' });
17
+ export async function main(args = []) {
18
+ const setting = generatorsSetting(await loadConfig(process.cwd()));
19
+ if (setting === false) {
20
+ console.error('\n✗ Il progetto ha spento i generatori con `features.generators: false`: quelli del pacchetto\n' +
21
+ ' scriverebbero codice che qui non si incastra.\n');
22
+ return 1;
23
+ }
24
+ if (setting === 'project' && missingInput(PROJECT_GENERATORS, "con `generators: 'project'` i generatori sono i suoi"))
25
+ return 1;
26
+ const env = setting === 'project' ? { ...process.env, OFFICINA_GENERATORS: 'project' } : process.env;
27
+ const result = spawnSync(process.execPath, plopCommand(args, process.cwd()), { stdio: 'inherit', env });
16
28
  return result.status ?? 1;
17
29
  }
@@ -0,0 +1,9 @@
1
+ import { type Hit } from './cli.ts';
2
+ export interface Manifest {
3
+ path: string;
4
+ source: string;
5
+ }
6
+ export interface ManifestHit extends Hit {
7
+ path: string;
8
+ }
9
+ export declare function manifestFindings(manifests: readonly Manifest[]): ManifestHit[];
@@ -0,0 +1,25 @@
1
+ import { readLines } from './cli.js';
2
+ import { isLive } from './workflows.js';
3
+ const EXPRESSION = /\$\{\{[^}]*\}\}/g;
4
+ const DESCRIPTION = /^\s*description:/;
5
+ const FORBIDDEN = ['secrets', 'needs', 'matrix'];
6
+ const contextIn = (expression) => FORBIDDEN.find((name) => new RegExp(`(^|[^\\w.])${name}\\.`).test(expression));
7
+ function lineFindings(text) {
8
+ const expressions = [...text.matchAll(EXPRESSION)];
9
+ if (expressions.length === 0)
10
+ return [];
11
+ if (DESCRIPTION.test(text)) {
12
+ return ['`description` non ammette espressioni: il runner la valuta e il manifesto non si carica'];
13
+ }
14
+ return expressions.flatMap(([expression]) => {
15
+ const context = contextIn(expression);
16
+ return context === undefined ? [] : [`\`${context}\` non è un contesto di una composite action: passalo come input`];
17
+ });
18
+ }
19
+ // Il runner valuta le espressioni del manifesto prima di eseguire qualunque passo: un contesto che una
20
+ // composite action non conosce non è un valore vuoto, è un'action che non si carica.
21
+ export function manifestFindings(manifests) {
22
+ return manifests.flatMap(({ path, source }) => readLines(source)
23
+ .filter(({ text }) => isLive(text))
24
+ .flatMap(({ n, text }) => lineFindings(text).map((message) => ({ path, line: n, message }))));
25
+ }
package/dist/lib/cli.d.ts CHANGED
@@ -17,6 +17,7 @@ export declare function cliOptions(args?: string[]): {
17
17
  };
18
18
  export type CliOptions = ReturnType<typeof cliOptions>;
19
19
  export declare const isGenerated: (path: string) => boolean;
20
+ export declare function missingInput(path: string, purpose: string): boolean;
20
21
  export declare function scopedPaths(options: CliOptions, isScanned: (path: string) => boolean): string[];
21
22
  export declare function printFindings(findings: Finding[], format: string): void;
22
23
  export declare function exitCode(findings: Finding[], strict: boolean): number;
package/dist/lib/cli.js CHANGED
@@ -22,6 +22,12 @@ export function cliOptions(args = process.argv.slice(2)) {
22
22
  // Un file generato non lo scrive nessuno, e il suo banner chiede di tenerlo fuori da linter e
23
23
  // formatter: una correzione ai suoi commenti sparirebbe alla prima rigenerazione.
24
24
  export const isGenerated = (path) => /\.gen\.[cm]?tsx?$/.test(path);
25
+ export function missingInput(path, purpose) {
26
+ if (existsSync(path))
27
+ return false;
28
+ console.error(`\n✗ ${path} non c'è: ${purpose}.\n`);
29
+ return true;
30
+ }
25
31
  export function scopedPaths(options, isScanned) {
26
32
  const candidates = options.diff ? changedFiles({ base: options.base, head: options.head }) : trackedAndUntracked();
27
33
  // ls-files elenca anche i file cancellati ma non ancora rimossi dall'indice.
@@ -3,6 +3,7 @@ import type { Representatives, VerifiedRoute } from './routes.ts';
3
3
  import type { SmokeCheck } from './smoke-production.ts';
4
4
  /** `'required'` fa fallire il gate quando ciò che deve controllare non c'è; `false` lo spegne. */
5
5
  export type FeatureSetting = 'required' | false;
6
+ export type GeneratorsSetting = FeatureSetting | 'project';
6
7
  export interface OfficinaConfig {
7
8
  /** L'apice di produzione, senza barra finale: lo usa `check smoke` quando non riceve un URL. */
8
9
  siteUrl?: string;
@@ -25,6 +26,7 @@ export interface OfficinaConfig {
25
26
  features?: {
26
27
  analytics?: FeatureSetting;
27
28
  roadmap?: FeatureSetting;
29
+ generators?: GeneratorsSetting;
28
30
  };
29
31
  placeholders?: {
30
32
  sources?: readonly string[];
@@ -35,10 +37,11 @@ export interface OfficinaConfig {
35
37
  representatives?: Representatives;
36
38
  };
37
39
  }
38
- export declare const FEATURES: readonly ['analytics', 'roadmap'];
40
+ export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators'];
39
41
  export type FeatureName = (typeof FEATURES)[number];
40
42
  /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
41
43
  export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
44
+ export declare const generatorsSetting: (config: OfficinaConfig) => GeneratorsSetting;
42
45
  export declare const defineConfig: (config: OfficinaConfig) => OfficinaConfig;
43
46
  export declare const findConfigFile: (root: string) => string | undefined;
44
47
  export declare function loadConfig(root: string): Promise<OfficinaConfig>;
@@ -10,12 +10,14 @@ import { existsSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
13
- export const FEATURES = ['analytics', 'roadmap'];
13
+ export const FEATURES = ['analytics', 'roadmap', 'generators'];
14
14
  /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
15
15
  export const isRequired = (config, name) => (config.features?.[name] ?? 'required') === 'required';
16
+ export const generatorsSetting = (config) => config.features?.generators ?? 'required';
16
17
  const budget = aShape({ label: aString, matches: aFunction, maxGzip: aNumber });
17
18
  const verifiedRoute = aShape({ path: aString, type: aString });
18
19
  const feature = oneOf("'required' oppure false", ['required', false]);
20
+ const generators = oneOf("'required', 'project' oppure false", ['required', 'project', false]);
19
21
  const SHAPE = aShape({
20
22
  siteUrl: aString,
21
23
  icons: aShape({ background: aString }),
@@ -26,7 +28,7 @@ const SHAPE = aShape({
26
28
  checks: anArrayOf(aFunction),
27
29
  }),
28
30
  analytics: aShape({ linkTracking: aString }),
29
- features: aShape({ analytics: feature, roadmap: feature }),
31
+ features: aShape({ analytics: feature, roadmap: feature, generators }),
30
32
  placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
31
33
  routes: aShape({ representatives: aRecordOf(aString) }),
32
34
  });
@@ -0,0 +1,3 @@
1
+ import type { ProjectFiles } from './alignment.ts';
2
+ import type { ContractGap } from './contract.ts';
3
+ export declare function dependabotGaps({ read: readFile }: ProjectFiles): ContractGap[];
@@ -0,0 +1,59 @@
1
+ import { parse } from 'yaml';
2
+ const DEPENDABOT = '.github/dependabot.yml';
3
+ const PACKAGE = '@elia97/officina';
4
+ const ACTIONS = 'Elia97/officina/*';
5
+ const GROUP_ONLY = ['commit-message', 'open-pull-requests-limit'];
6
+ const gap = (message) => ({ path: DEPENDABOT, message });
7
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
8
+ function read(source) {
9
+ try {
10
+ const document = parse(source);
11
+ return isRecord(document) ? document : 'non è una mappa YAML';
12
+ }
13
+ catch (error) {
14
+ return `non è YAML valido: ${String(error).replace(/\n[\s\S]*$/, '')}`;
15
+ }
16
+ }
17
+ const inGroup = (updates) => updates.filter((u) => u['multi-ecosystem-group'] !== undefined);
18
+ const covers = (update, needle) => (update.patterns ?? []).includes(needle);
19
+ function groupGaps(document, updates) {
20
+ const groups = document['multi-ecosystem-groups'];
21
+ if (!isRecord(groups) || Object.keys(groups).length === 0) {
22
+ return [gap('non dichiara `multi-ecosystem-groups`: il pacchetto e le sue action si alzano separati')];
23
+ }
24
+ return inGroup(updates)
25
+ .filter((update) => !(update['multi-ecosystem-group'] in groups))
26
+ .map((update) => gap(`la voce \`${update['package-ecosystem']}\` cita il gruppo \`${update['multi-ecosystem-group']}\`, che non esiste`));
27
+ }
28
+ const misplacedGaps = (updates) => inGroup(updates).flatMap((update) => GROUP_ONLY.filter((key) => update[key] !== undefined).map((key) => gap(`\`${key}\` sta sulla voce \`${update['package-ecosystem']}\` del gruppo: va sul gruppo, e Dependabot rifiuta il file`)));
29
+ const coverageGaps = (updates) => [
30
+ { ecosystem: 'npm', needle: PACKAGE, reason: 'il gruppo deve prendere il pacchetto da npm' },
31
+ { ecosystem: 'github-actions', needle: ACTIONS, reason: 'il gruppo deve prendere le action da github-actions' },
32
+ ]
33
+ .filter(({ ecosystem, needle }) => !inGroup(updates).some((u) => u['package-ecosystem'] === ecosystem && covers(u, needle)))
34
+ .map(({ needle, reason }) => gap(`nessuna voce del gruppo copre \`${needle}\`: ${reason}`));
35
+ // `patterns` restringe l'intera voce: il resto dell'ecosistema smette di aggiornarsi e nessun check diventa rosso.
36
+ function restGaps(updates) {
37
+ const restrained = inGroup(updates).filter((update) => (update.patterns ?? []).length > 0);
38
+ return restrained.flatMap((update) => {
39
+ const ecosystem = update['package-ecosystem'];
40
+ const rest = updates.find((other) => other['package-ecosystem'] === ecosystem && other['multi-ecosystem-group'] === undefined);
41
+ if (rest === undefined) {
42
+ return [gap(`\`${ecosystem}\` sta solo nella voce del gruppo, con \`patterns\`: il resto non si aggiorna più`)];
43
+ }
44
+ return rest['commit-message'] === undefined
45
+ ? [gap(`la voce \`${ecosystem}\` fuori dal gruppo non ha \`commit-message\`: le sue PR non passano il titolo`)]
46
+ : [];
47
+ });
48
+ }
49
+ export function dependabotGaps({ read: readFile }) {
50
+ const source = readFile(DEPENDABOT);
51
+ if (source === undefined) {
52
+ return [gap('manca: senza, il pacchetto e le sue action si alzano separati')];
53
+ }
54
+ const document = read(source);
55
+ if (typeof document === 'string')
56
+ return [gap(document)];
57
+ const updates = document.updates ?? [];
58
+ return [...groupGaps(document, updates), ...misplacedGaps(updates), ...coverageGaps(updates), ...restGaps(updates)];
59
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ProjectFiles } from './alignment.ts';
2
2
  import type { ContractGap } from './contract.ts';
3
+ export declare const isLive: (line: string) => boolean;
3
4
  export declare const mentions: (source: string | undefined, needle: string) => boolean;
4
5
  /** Le action del progetto devono stare alla stessa versione del pacchetto installato. */
5
6
  export declare function workflowGaps({ read }: ProjectFiles, version: string): ContractGap[];
6
- export declare function dependabotGaps({ read }: ProjectFiles): ContractGap[];
@@ -1,7 +1,7 @@
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('#'));
1
+ // Niente parser YAML nel pacchetto: la ricerca è testuale. Una riga commentata non soddisfa nessun
2
+ // controllo, altrimenti basta commentare un passo per avere un progetto «a posto» che non lo lancia.
3
+ export const isLive = (line) => !line.trimStart().startsWith('#');
4
+ const liveLines = (source) => source.split(/\r?\n/).filter(isLive);
5
5
  export const mentions = (source, needle) => source !== undefined && liveLines(source).some((line) => line.includes(needle));
6
6
  const ACTIONS = [
7
7
  { workflow: '.github/workflows/ci.yml', action: 'ci' },
@@ -50,21 +50,3 @@ export function workflowGaps({ read }, version) {
50
50
  return message === undefined ? [] : [{ path: workflow, message }];
51
51
  });
52
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
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elia97/officina",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità, generatori e verifiche di build e di produzione.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@biomejs/biome": "2.5.10",
58
- "@types/node": "^24.13.5",
58
+ "@types/node": "^26.5.1",
59
59
  "@vitest/coverage-v8": "^4.1.11",
60
60
  "fallow": "^3.26.0",
61
61
  "typescript": "^7.0.2",
@@ -63,7 +63,8 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "plop": "^4.0.5",
66
- "ts-morph": "^28.0.0"
66
+ "ts-morph": "^28.0.0",
67
+ "yaml": "^2.9.1"
67
68
  },
68
69
  "peerDependencies": {
69
70
  "@biomejs/biome": ">=2.5.10 <3.0.0",