@elia97/officina 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +88 -12
  2. package/dist/bin.js +5 -1
  3. package/dist/checks/bundle.js +15 -5
  4. package/dist/checks/doctor.js +32 -16
  5. package/dist/checks/lighthouse.js +4 -1
  6. package/dist/checks/links.d.ts +2 -0
  7. package/dist/checks/links.js +69 -0
  8. package/dist/checks/migrate.d.ts +2 -0
  9. package/dist/checks/migrate.js +44 -0
  10. package/dist/checks/placeholders.js +15 -6
  11. package/dist/checks/secrets.d.ts +2 -0
  12. package/dist/checks/secrets.js +46 -0
  13. package/dist/checks/smoke.js +23 -6
  14. package/dist/index.d.ts +1 -0
  15. package/dist/lib/alignment.d.ts +1 -0
  16. package/dist/lib/alignment.js +8 -2
  17. package/dist/lib/bundle-ssr.d.ts +2 -0
  18. package/dist/lib/bundle-ssr.js +1 -1
  19. package/dist/lib/check-placeholders.d.ts +3 -1
  20. package/dist/lib/check-placeholders.js +9 -3
  21. package/dist/lib/check-routes.js +5 -3
  22. package/dist/lib/cli.d.ts +1 -0
  23. package/dist/lib/cli.js +1 -0
  24. package/dist/lib/config-gaps.d.ts +5 -5
  25. package/dist/lib/config-gaps.js +9 -8
  26. package/dist/lib/config.d.ts +31 -3
  27. package/dist/lib/config.js +30 -4
  28. package/dist/lib/database-auth.d.ts +5 -0
  29. package/dist/lib/database-auth.js +81 -0
  30. package/dist/lib/env-schema.d.ts +8 -0
  31. package/dist/lib/env-schema.js +37 -0
  32. package/dist/lib/links.d.ts +30 -0
  33. package/dist/lib/links.js +101 -0
  34. package/dist/lib/migrate.d.ts +11 -0
  35. package/dist/lib/migrate.js +29 -0
  36. package/dist/lib/routes.d.ts +19 -7
  37. package/dist/lib/routes.js +39 -13
  38. package/dist/lib/secrets.d.ts +10 -0
  39. package/dist/lib/secrets.js +53 -0
  40. package/dist/lib/smoke-on-demand.d.ts +8 -0
  41. package/dist/lib/smoke-on-demand.js +65 -0
  42. package/dist/lib/smoke-production.d.ts +4 -1
  43. package/dist/lib/smoke-production.js +2 -0
  44. package/dist/lib/validate.d.ts +2 -0
  45. package/dist/lib/validate.js +9 -1
  46. package/dist/lib/vercel-build.d.ts +1 -0
  47. package/dist/lib/vercel-build.js +26 -4
  48. package/dist/lib/workflows.d.ts +14 -0
  49. package/dist/lib/workflows.js +44 -3
  50. package/dist/smoke.d.ts +1 -0
  51. package/dist/smoke.js +1 -0
  52. package/package.json +1 -1
  53. package/presets/biome.base.json +2 -1
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import process from 'node:process';
4
+ import { parseArgs } from 'node:util';
5
+ import { cliOptions, exitCode, missingInput, printFindings } from '../lib/cli.js';
6
+ import { envSchema } from '../lib/env-schema.js';
7
+ import { filesWithExtension } from '../lib/routes.js';
8
+ import { canaryLines, scanSecrets, secretFields } from '../lib/secrets.js';
9
+ const SCHEMA = 'astro.config.mjs';
10
+ const DIST = 'dist/client';
11
+ const ADVICE = 'Una chiave server e secret si legge solo nel codice che gira sul server: segui la strada che la porta nel file nominato';
12
+ function check(keys) {
13
+ const counted = keys.length === 1 ? '1 chiave' : `${keys.length} chiavi`;
14
+ console.log(`\ncheck:secrets — ${counted} server e secret di env.schema, cercate in ${DIST}\n`);
15
+ if (keys.length === 0) {
16
+ console.log(' Nessuna chiave segreta nello schema: niente da verificare.\n');
17
+ return 0;
18
+ }
19
+ if (missingInput(DIST, 'è la build in cui si cercano le chiavi: prima `pnpm build`'))
20
+ return 1;
21
+ const files = filesWithExtension(DIST, '');
22
+ if (files.length === 0) {
23
+ console.error(`\n✗ ${DIST} è vuota: senza file il controllo non afferma niente.\n`);
24
+ return 1;
25
+ }
26
+ const { findings, verified } = scanSecrets(keys, process.env, files, (path) => readFileSync(path));
27
+ printFindings(findings, cliOptions([]).format);
28
+ if (verified.length > 0)
29
+ console.log(` Verificate: ${verified.join(', ')}.`);
30
+ if (findings.some(({ severity }) => severity === 'error'))
31
+ console.log(`\n ${ADVICE}.\n`);
32
+ return exitCode(findings, false);
33
+ }
34
+ export function main(args = process.argv.slice(2)) {
35
+ const { canaries } = parseArgs({ args, options: { canaries: { type: 'boolean', default: false } } }).values;
36
+ if (missingInput(SCHEMA, 'le chiavi segrete stanno nel suo `env.schema`'))
37
+ return 1;
38
+ const secrets = secretFields(envSchema(readFileSync(SCHEMA, 'utf8')));
39
+ if (!canaries)
40
+ return check(secrets.map(({ key }) => key));
41
+ for (const line of canaryLines(secrets))
42
+ console.log(line);
43
+ return 0;
44
+ }
45
+ if (import.meta.main)
46
+ process.exit(main());
@@ -1,26 +1,38 @@
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
+ import { existsSync, readFileSync } from 'node:fs';
3
4
  import process from 'node:process';
5
+ import { readManifest } from '../lib/alignment.js';
4
6
  import { missingInput } from '../lib/cli.js';
5
- import { isRequired, loadConfig } from '../lib/config.js';
7
+ import { isOn, isRequired, loadConfig } from '../lib/config.js';
6
8
  import { expectedRoutes, readPageFiles, smokeRoutes } from '../lib/routes.js';
9
+ import { onDemandPages } from '../lib/smoke-on-demand.js';
7
10
  import { runChecks, SECURITY_HEADERS, waitForAlias, } from '../lib/smoke-production.js';
8
11
  const PAGES_DIR = 'src/pages';
12
+ const projectFile = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : undefined);
9
13
  function printResults(results) {
10
14
  for (const { check, status, detail } of results) {
11
15
  if (status === 'pass')
12
16
  console.log(`✓ ${check}`);
13
17
  else if (status === 'skip')
14
18
  console.log(`- ${check} (saltato: ${detail})`);
19
+ else if (status === 'warn')
20
+ console.log(`· ${check} — ${detail}`);
15
21
  else
16
22
  console.error(`✗ ${check} — ${detail}`);
17
23
  }
18
24
  }
19
- function printFailures(failures) {
25
+ const disabledResults = ({ disabled }) => disabled.map(({ label }) => ({ check: `pagina ${label}`, status: 'skip', detail: 'spenta da `routes.disabled`' }));
26
+ function printFailures(failures, database) {
20
27
  console.error(`\n✗ ${failures.length} controllo/i falliti:`);
21
28
  console.error(`${failures.map(({ check, detail }) => ` - ${check} — ${detail}`).join('\n')}\n`);
22
29
  console.error('La produzione è online e rotta. Torna indietro dalla dashboard di Vercel:');
23
30
  console.error('Deployments → l’ultimo deployment di produzione sano → Promote to Production.\n');
31
+ if (!database)
32
+ return;
33
+ console.error('Con un database lo schema non torna indietro: le migrazioni già applicate restano,');
34
+ console.error('e il deployment che promuovi regge solo se sono compatibili con il suo codice.');
35
+ console.error('La regola è nel README di officina: «Migrazioni compatibili con la produzione».\n');
24
36
  }
25
37
  export async function main(args = []) {
26
38
  const config = await loadConfig(process.cwd());
@@ -45,17 +57,22 @@ export async function main(args = []) {
45
57
  };
46
58
  if (missingInput(PAGES_DIR, 'le rotte da visitare si derivano da lì'))
47
59
  return 1;
48
- const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR);
60
+ const expected = expectedRoutes(readPageFiles(PAGES_DIR), PAGES_DIR, routes.disabled);
49
61
  const pages = smokeRoutes(expected, smoke.nonHtmlRoutes, routes.representatives);
50
62
  console.log(`\nSmoke di produzione — ${baseUrl}\n`);
51
63
  await waitForAlias(context, (ms) => new Promise((resolve) => setTimeout(resolve, ms)));
52
- const results = await runChecks(context, pages, smoke.checks);
53
- printResults(results);
64
+ const onDemand = onDemandPages(expected, PAGES_DIR, routes.representatives);
65
+ const results = await runChecks({ ...context, onDemand }, pages, smoke.checks);
66
+ printResults([...results, ...disabledResults(expected)]);
54
67
  const failures = results.filter(({ status }) => status === 'fail');
55
68
  if (failures.length > 0) {
56
- printFailures(failures);
69
+ printFailures(failures, isOn(config, readManifest({ read: projectFile }), 'database'));
57
70
  return 1;
58
71
  }
72
+ const warnings = results.filter(({ status }) => status === 'warn').length;
73
+ // TODO: dalla 0.13.0 le anomalie a richiesta sono `fail` (smoke-on-demand.ts): togli il log degli avvisi qui sotto.
74
+ if (warnings > 0)
75
+ console.log(`\n· ${warnings} avviso/i: dalla 0.13.0 fanno fallire lo smoke.`);
59
76
  console.log(`\n✓ Tutti i controlli passati su ${baseUrl}.\n`);
60
77
  return 0;
61
78
  }
package/dist/index.d.ts CHANGED
@@ -3,4 +3,5 @@ export type { OfficinaConfig } from './lib/config.ts';
3
3
  export { defineConfig } from './lib/config.ts';
4
4
  export type { IconSpec } from './lib/icon-specs.ts';
5
5
  export type { VerifiedRoute } from './lib/routes.ts';
6
+ export type { OnDemandPage } from './lib/smoke-on-demand.ts';
6
7
  export type { CheckResult, SmokeCheck, SmokeContext } from './lib/smoke-production.ts';
@@ -13,6 +13,7 @@ export declare const EXPECTED_SCRIPTS: Readonly<Record<string, string>>;
13
13
  export declare const CI_STEPS: readonly string[];
14
14
  export declare function scriptGaps({ scripts }: Manifest): ContractGap[];
15
15
  export declare function dependencyGaps({ dependencies, devDependencies }: Manifest): ContractGap[];
16
+ export declare const readManifest: (files: Pick<ProjectFiles, 'read'>) => Manifest;
16
17
  export declare function leftoverGaps(files: ProjectFiles): ContractGap[];
17
18
  export declare const BIOME_PRESET = "@elia97/officina/biome";
18
19
  export declare const BIOME_SCHEMA = "./node_modules/@biomejs/biome/configuration_schema.json";
@@ -6,6 +6,8 @@ export const EXPECTED_SCRIPTS = {
6
6
  'check:roadmap': 'officina check roadmap',
7
7
  'check:placeholders': 'officina check placeholders',
8
8
  'perf:bundle': 'officina check bundle',
9
+ 'check:links': 'officina check links',
10
+ 'check:secrets': 'officina check secrets',
9
11
  'smoke:prod': 'officina check smoke',
10
12
  'analytics:verify': 'officina check analytics',
11
13
  lhci: 'officina check lighthouse',
@@ -26,14 +28,17 @@ export const CI_STEPS = [
26
28
  'doctor',
27
29
  ];
28
30
  const MANIFEST = 'package.json';
31
+ // TODO: dalla 0.13.0 `check:secrets` è un errore di doctor: toglilo da NEW_SCRIPTS.
32
+ const NEW_SCRIPTS = new Set(['check:secrets']);
29
33
  export function scriptGaps({ scripts = {} }) {
30
34
  const gaps = [];
31
35
  for (const [name, expected] of Object.entries(EXPECTED_SCRIPTS)) {
32
36
  const actual = scripts[name];
37
+ const severity = NEW_SCRIPTS.has(name) ? { severity: 'warning' } : {};
33
38
  if (actual === undefined)
34
- gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}\`` });
39
+ gaps.push({ path: MANIFEST, message: `script \`${name}\` assente: atteso \`${expected}\``, ...severity });
35
40
  else if (!actual.startsWith(expected))
36
- gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}\`` });
41
+ gaps.push({ path: MANIFEST, message: `script \`${name}\` è \`${actual}\`: atteso \`${expected}\``, ...severity });
37
42
  }
38
43
  const ci = scripts.ci ?? '';
39
44
  for (const step of CI_STEPS) {
@@ -110,6 +115,7 @@ function readJson({ read }, path) {
110
115
  return undefined;
111
116
  }
112
117
  }
118
+ export const readManifest = (files) => (readJson(files, MANIFEST) ?? {});
113
119
  export function leftoverGaps(files) {
114
120
  const gaps = LEFTOVERS.filter(({ path }) => holds(files.paths, path)).map(({ path, reason }) => ({
115
121
  path,
@@ -1,3 +1,4 @@
1
+ import { type ServerFile } from './vercel-build.ts';
1
2
  export type PageEntries = {
2
3
  route: string;
3
4
  entries: string[];
@@ -10,5 +11,6 @@ export type SsrReading = {
10
11
  comparesExpectedRoutes: boolean;
11
12
  measured: boolean;
12
13
  };
14
+ export declare function readServerFiles(): ServerFile[];
13
15
  /** Le rotte renderizzate a richiesta non emettono HTML: le dà il manifest che Astro scrive nella build Vercel. */
14
16
  export declare function readSsr(hasHtml: boolean): SsrReading;
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { ssrRoutes } from './vercel-build.js';
4
4
  const FUNCTIONS = '.vercel/output/_functions';
5
5
  const SERVER_OUTPUT = 'dist/server';
6
- function readServerFiles() {
6
+ export function readServerFiles() {
7
7
  const read = (path) => ({ path, source: readFileSync(join(FUNCTIONS, path), 'utf8') });
8
8
  const chunks = existsSync(join(FUNCTIONS, 'chunks')) ? readdirSync(join(FUNCTIONS, 'chunks')) : [];
9
9
  const entry = existsSync(join(FUNCTIONS, 'entry.mjs')) ? ['entry.mjs'] : [];
@@ -1,8 +1,10 @@
1
+ import type { Manifest } from './alignment.ts';
1
2
  import { type Finding, type Hit } from './cli.ts';
2
- import type { OfficinaConfig } from './config.ts';
3
+ import { type OfficinaConfig } from './config.ts';
3
4
  type EnvHit = Omit<Finding, 'path'>;
4
5
  export declare function placeholderSources(config?: OfficinaConfig): string[];
5
6
  export declare const contactEnvKeys: (config?: OfficinaConfig) => readonly string[];
7
+ export declare function requiredEnvKeys(config: OfficinaConfig, manifest: Manifest): string[];
6
8
  export declare function sourceFindings(source: string): Hit[];
7
9
  export declare function envFindings(content: string, keys: readonly string[]): EnvHit[];
8
10
  export declare const outputFindings: (dir: string) => Finding[];
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { parseEnv } from 'node:util';
4
4
  import { readLines } from './cli.js';
5
+ import { DEPENDENCY_FEATURES, featuresOn } from './config.js';
5
6
  const DICTIONARIES = 'src/i18n/strings';
6
7
  const DEFAULT_SOURCES = ['src/lib/site.ts', 'src/lib/company.ts'];
7
8
  const CONTACT_ENV_KEYS = ['CONTACT_FROM_EMAIL', 'CONTACT_FROM_NAME', 'CONTACT_TO_EMAIL'];
@@ -11,7 +12,7 @@ const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
11
12
  const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
12
13
  const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
13
14
  const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
14
- // `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@59.22.0: `vercel pull` lo scrive al posto del valore di una Sensitive.
15
+ // `SENSITIVE_ENV_VALUE_PLACEHOLDER` di vercel@62.2.0: `vercel pull` lo scrive al posto del valore di una Secret.
15
16
  const SENSITIVE = '[SENSITIVE]';
16
17
  const WITHOUT_VALUE = 'il deploy andrebbe online senza il suo valore';
17
18
  const dictionaries = () => existsSync(DICTIONARIES)
@@ -27,6 +28,11 @@ export function placeholderSources(config = {}) {
27
28
  return [...DEFAULT_SOURCES, ...dictionaries()];
28
29
  }
29
30
  export const contactEnvKeys = (config = {}) => config.placeholders?.contactEnvKeys ?? CONTACT_ENV_KEYS;
31
+ export function requiredEnvKeys(config, manifest) {
32
+ const on = featuresOn(config, manifest);
33
+ const secrets = on.map((name) => DEPENDENCY_FEATURES[name].secret);
34
+ return [...new Set([...contactEnvKeys(config), ...secrets])];
35
+ }
30
36
  function literalFindings(value) {
31
37
  const messages = [];
32
38
  const token = value.match(TOKEN);
@@ -64,7 +70,7 @@ export function envFindings(content, keys) {
64
70
  { line: declaration.n, severity, message: `${key} ${message}` },
65
71
  ];
66
72
  if (value === SENSITIVE)
67
- return hit('warning', 'è Sensitive su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
73
+ return hit('warning', 'è Secret su Vercel: vercel pull non ne scarica il valore, e il deploy non lo può verificare');
68
74
  if (value === '')
69
75
  return hit('error', `è vuota: ${WITHOUT_VALUE}`);
70
76
  if (TOKEN.test(value) || DOMAIN.test(value))
@@ -84,5 +90,5 @@ export const outputFindings = (dir) => filesUnder(dir)
84
90
  .map((path) => ({
85
91
  path,
86
92
  severity: 'error',
87
- message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Sensitive: la build l'ha incorporato al posto del valore`,
93
+ message: `contiene ${SENSITIVE}, il segnaposto che vercel pull scrive per una variabile Secret: la build l'ha incorporato al posto del valore`,
88
94
  }));
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { posix } from 'node:path';
3
+ import { DRIZZLE_CONFIG } from './database-auth.js';
3
4
  import { blankFences, blankSpans, linkDestinations } from './markdown.js';
4
5
  const SCANNED = /\.md$/;
5
6
  const ROADMAP = 'docs/ROADMAP.md';
@@ -10,14 +11,14 @@ const IGNORED = [
10
11
  /\.pdf$/,
11
12
  /^docs\/sources\//,
12
13
  ];
13
- const PATH_REFERENCE = /`((?:docs|src|scripts|test|e2e|public|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
14
+ const PATH_REFERENCE = /`((?:docs|src|scripts|test|e2e|public|drizzle|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
14
15
  const NAME_REFERENCE = /`([A-Za-z0-9._-]+)`/g;
15
16
  // La virgola non delimita: i titoli la contengono.
16
17
  const SECTION_REFERENCE = /`([A-Za-z0-9._/-]+\.md)`\s*§\s*([^.·|)§]{1,80})/g;
17
18
  const LINK_TEXT = /\[[^[\]]*\]\(/g;
18
19
  const ROOT_DOCUMENT = /^[A-Z][A-Z0-9_-]*\.md$/;
19
20
  // I file tracciati alla radice di template/vetrina (git ls-files), Markdown esclusi.
20
- const ROOT_CONFIG = new Set([
21
+ const VETRINA_ROOT = [
21
22
  '.editorconfig',
22
23
  '.env.example',
23
24
  '.fallowrc.jsonc',
@@ -39,7 +40,8 @@ const ROOT_CONFIG = new Set([
39
40
  'tsconfig.json',
40
41
  'vercel.json',
41
42
  'vitest.config.ts',
42
- ]);
43
+ ];
44
+ const ROOT_CONFIG = new Set([...VETRINA_ROOT, DRIZZLE_CONFIG]);
43
45
  const CHANGELOG = /(?:^|\/)CHANGELOG\.md$/;
44
46
  const SITE_CONTENT = /^src\/content\//;
45
47
  const EXTERNAL = /^(?:[a-z][a-z0-9+.-]*:|#|\/)/i;
package/dist/lib/cli.d.ts CHANGED
@@ -19,6 +19,7 @@ export type CliOptions = ReturnType<typeof cliOptions>;
19
19
  export declare const isGenerated: (path: string) => boolean;
20
20
  export declare function missingInput(path: string, purpose: string): boolean;
21
21
  export declare function scopedPaths(options: CliOptions, isScanned: (path: string) => boolean): string[];
22
+ export declare const maskCommand: (value: string) => string;
22
23
  export declare function printFindings(findings: Finding[], format: string): void;
23
24
  export declare function exitCode(findings: Finding[], strict: boolean): number;
24
25
  export declare const readLines: (text: string) => {
package/dist/lib/cli.js CHANGED
@@ -38,6 +38,7 @@ export function scopedPaths(options, isScanned) {
38
38
  }
39
39
  // I workflow command di GitHub vogliono %, CR e LF codificati nel messaggio.
40
40
  const escapeForGitHub = (text) => text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
41
+ export const maskCommand = (value) => `::add-mask::${escapeForGitHub(value)}`;
41
42
  export function printFindings(findings, format) {
42
43
  for (const finding of findings) {
43
44
  const location = finding.line === undefined ? '' : `:${finding.line}`;
@@ -1,7 +1,7 @@
1
1
  import { type OfficinaConfig } from './config.ts';
2
2
  import type { ContractGap } from './contract.ts';
3
- /**
4
- * `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se
5
- * il file non c'è; `patterns` i pattern dinamici di `src/pages` rimasti senza rappresentante.
6
- */
7
- export declare function configGaps(loaded: OfficinaConfig | Error | undefined, patterns?: readonly string[]): ContractGap[];
3
+ export type RouteGaps = {
4
+ missingRepresentatives?: readonly string[];
5
+ unknownDisabled?: readonly string[];
6
+ };
7
+ export declare function configGaps(loaded: OfficinaConfig | Error | undefined, { missingRepresentatives, unknownDisabled }?: RouteGaps): ContractGap[];
@@ -1,5 +1,6 @@
1
1
  import { FEATURES } from './config.js';
2
2
  const CONFIG = 'officina.config.ts';
3
+ const asGap = (message) => ({ path: CONFIG, message });
3
4
  function siteUrlGap(siteUrl) {
4
5
  if (siteUrl === undefined)
5
6
  return '`siteUrl` assente: `check smoke` non ha un host canonico';
@@ -8,13 +9,10 @@ function siteUrlGap(siteUrl) {
8
9
  return siteUrl.endsWith('/') ? `\`siteUrl\` finisce con una barra: \`${siteUrl}\`` : undefined;
9
10
  }
10
11
  // Il silenzio vale `'required'`: un controllo si spegne dichiarandolo, non dimenticandolo.
11
- const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) => `\`features.${name}\` non dichiarata: vale \`'required'\`, e il gate fallisce se non trova cosa controllare`);
12
+ const featureGaps = (config) => FEATURES.filter((name) => config.features?.[name] === undefined).map((name) => asGap(`\`features.${name}\` non dichiarata: vale \`'required'\`, e il gate fallisce se non trova cosa controllare`));
12
13
  const representativeGaps = (patterns) => patterns.map((label) => `\`routes.representatives\` non ha un percorso per \`${label}\`: smoke e Lighthouse non guardano quella pagina`);
13
- /**
14
- * `loaded` è la configurazione letta, l'errore che il suo caricamento ha sollevato, o `undefined` se
15
- * il file non c'è; `patterns` i pattern dinamici di `src/pages` rimasti senza rappresentante.
16
- */
17
- export function configGaps(loaded, patterns = []) {
14
+ const disabledGaps = (labels) => labels.map((label) => `\`routes.disabled\` nomina \`${label}\`, che non è una pagina dinamica prerenderizzata di \`src/pages\`: non spegne niente`);
15
+ export function configGaps(loaded, { missingRepresentatives = [], unknownDisabled = [] } = {}) {
18
16
  if (loaded === undefined)
19
17
  return [{ path: CONFIG, message: 'manca: i valori del progetto per officina stanno qui' }];
20
18
  if (loaded instanceof Error)
@@ -22,8 +20,11 @@ export function configGaps(loaded, patterns = []) {
22
20
  const messages = [
23
21
  siteUrlGap(loaded.siteUrl),
24
22
  loaded.icons === undefined ? '`icons.background` assente: `gen icons` non ha un colore di fondo' : undefined,
23
+ ].filter((message) => message !== undefined);
24
+ return [
25
+ ...messages.map(asGap),
25
26
  ...featureGaps(loaded),
26
- ...representativeGaps(patterns),
27
+ ...representativeGaps(missingRepresentatives).map(asGap),
28
+ ...disabledGaps(unknownDisabled).map(asGap),
27
29
  ];
28
- return messages.filter((message) => message !== undefined).map((message) => ({ path: CONFIG, message }));
29
30
  }
@@ -1,3 +1,4 @@
1
+ import type { Manifest } from './alignment.ts';
1
2
  import type { Budget } from './bundle-budget.ts';
2
3
  import type { Representatives, VerifiedRoute } from './routes.ts';
3
4
  import type { SmokeCheck } from './smoke-production.ts';
@@ -22,27 +23,54 @@ export interface OfficinaConfig {
22
23
  analytics?: {
23
24
  linkTracking?: string;
24
25
  };
25
- /** Quali controlli il progetto pretende. Una voce non dichiarata vale `'required'`. */
26
+ /**
27
+ * Quali controlli il progetto pretende. Una voce non dichiarata vale `'required'`, tranne `database`
28
+ * e `auth`, che senza dichiarazione seguono le dipendenze di `package.json`.
29
+ */
26
30
  features?: {
27
31
  analytics?: FeatureSetting;
28
32
  roadmap?: FeatureSetting;
29
33
  generators?: GeneratorsSetting;
30
34
  botId?: FeatureSetting;
35
+ links?: FeatureSetting;
36
+ database?: FeatureSetting;
37
+ auth?: FeatureSetting;
31
38
  };
32
39
  placeholders?: {
33
40
  sources?: readonly string[];
34
41
  contactEnvKeys?: readonly string[];
35
42
  };
36
- /** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
43
+ /** La variabile con cui girano le migrazioni; senza dichiarazione, `DATABASE_URL_UNPOOLED`. */
44
+ database?: {
45
+ migrationUrlKey?: string;
46
+ };
37
47
  routes?: {
48
+ /** Per ogni pattern dinamico di `src/pages`, un percorso vero che smoke e Lighthouse visitano. */
38
49
  representatives?: Representatives;
50
+ /** I pattern dinamici di `src/pages` spenti per scelta: non emettono pagine, e nessun gate le pretende. */
51
+ disabled?: readonly string[];
39
52
  };
40
53
  }
41
- export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId'];
54
+ export declare const FEATURES: readonly ['analytics', 'roadmap', 'generators', 'botId', 'links'];
42
55
  export type FeatureName = (typeof FEATURES)[number];
43
56
  /** Un controllo si spegne solo scrivendolo: il silenzio vale `'required'`. */
44
57
  export declare const isRequired: (config: OfficinaConfig, name: FeatureName) => boolean;
45
58
  export declare const generatorsSetting: (config: OfficinaConfig) => GeneratorsSetting;
59
+ export declare const DEPENDENCY_FEATURES: {
60
+ readonly database: {
61
+ readonly dependency: 'drizzle-orm';
62
+ readonly secret: 'DATABASE_URL';
63
+ };
64
+ readonly auth: {
65
+ readonly dependency: 'better-auth';
66
+ readonly secret: 'BETTER_AUTH_SECRET';
67
+ };
68
+ };
69
+ export type DependencyFeature = keyof typeof DEPENDENCY_FEATURES;
70
+ export declare function isOn(config: OfficinaConfig, manifest: Manifest, name: DependencyFeature): boolean;
71
+ export declare const featuresOn: (config: OfficinaConfig, manifest: Manifest) => DependencyFeature[];
72
+ export declare const featuresOffByChoice: (config: OfficinaConfig, manifest: Manifest) => DependencyFeature[];
73
+ export declare const migrationUrlKey: (config: OfficinaConfig) => string;
46
74
  export declare const defineConfig: (config: OfficinaConfig) => OfficinaConfig;
47
75
  export declare const findConfigFile: (root: string) => string | undefined;
48
76
  export declare function loadConfig(root: string): Promise<OfficinaConfig>;
@@ -9,11 +9,28 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { existsSync } from 'node:fs';
10
10
  import { basename, join } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
- import { aFunction, aNumber, anArrayOf, aRecordOf, aShape, aString, aStringOrNull, oneOf } from './validate.js';
13
- export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId'];
12
+ import { aFunction, aNumber, anArrayOf, anEnvName, aRecordOf, aShape, aString, aStringOrNull, isRecord, oneOf, } from './validate.js';
13
+ export const FEATURES = ['analytics', 'roadmap', 'generators', 'botId', 'links'];
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
16
  export const generatorsSetting = (config) => config.features?.generators ?? 'required';
17
+ export const DEPENDENCY_FEATURES = {
18
+ database: { dependency: 'drizzle-orm', secret: 'DATABASE_URL' },
19
+ auth: { dependency: 'better-auth', secret: 'BETTER_AUTH_SECRET' },
20
+ };
21
+ const DEPENDENCY_FEATURE_NAMES = Object.keys(DEPENDENCY_FEATURES);
22
+ const declares = (dependencies, name) => isRecord(dependencies) && name in dependencies;
23
+ const dependsOn = ({ dependencies, devDependencies }, name) => {
24
+ const { dependency } = DEPENDENCY_FEATURES[name];
25
+ return declares(dependencies, dependency) || declares(devDependencies, dependency);
26
+ };
27
+ export function isOn(config, manifest, name) {
28
+ const declared = config.features?.[name];
29
+ return declared === undefined ? dependsOn(manifest, name) : declared === 'required';
30
+ }
31
+ export const featuresOn = (config, manifest) => DEPENDENCY_FEATURE_NAMES.filter((name) => isOn(config, manifest, name));
32
+ export const featuresOffByChoice = (config, manifest) => DEPENDENCY_FEATURE_NAMES.filter((name) => config.features?.[name] === false && dependsOn(manifest, name));
33
+ export const migrationUrlKey = (config) => config.database?.migrationUrlKey ?? 'DATABASE_URL_UNPOOLED';
17
34
  const budget = aShape({ label: aString, matches: aFunction, maxGzip: aNumber });
18
35
  const verifiedRoute = aShape({ path: aString, type: aString });
19
36
  const feature = oneOf("'required' oppure false", ['required', false]);
@@ -28,9 +45,18 @@ const SHAPE = aShape({
28
45
  checks: anArrayOf(aFunction),
29
46
  }),
30
47
  analytics: aShape({ linkTracking: aString }),
31
- features: aShape({ analytics: feature, roadmap: feature, generators, botId: feature }),
48
+ features: aShape({
49
+ analytics: feature,
50
+ roadmap: feature,
51
+ generators,
52
+ botId: feature,
53
+ links: feature,
54
+ database: feature,
55
+ auth: feature,
56
+ }),
32
57
  placeholders: aShape({ sources: anArrayOf(aString), contactEnvKeys: anArrayOf(aString) }),
33
- routes: aShape({ representatives: aRecordOf(aString) }),
58
+ database: aShape({ migrationUrlKey: anEnvName }),
59
+ routes: aShape({ representatives: aRecordOf(aString), disabled: anArrayOf(aString) }),
34
60
  });
35
61
  const CONFIG_FILES = ['officina.config.ts', 'officina.config.mjs', 'officina.config.js'];
36
62
  export const defineConfig = (config) => config;
@@ -0,0 +1,5 @@
1
+ import type { Manifest, ProjectFiles } from './alignment.ts';
2
+ import { type DependencyFeature } from './config.ts';
3
+ import type { ContractGap } from './contract.ts';
4
+ export declare const DRIZZLE_CONFIG = "drizzle.config.ts";
5
+ export declare function databaseAuthGaps(files: ProjectFiles, manifest: Manifest, on: readonly DependencyFeature[]): ContractGap[];
@@ -0,0 +1,81 @@
1
+ import { DEPENDENCY_FEATURES } from './config.js';
2
+ import { envSchema } from './env-schema.js';
3
+ import { actionInputGaps } from './workflows.js';
4
+ const MANIFEST = 'package.json';
5
+ export const DRIZZLE_CONFIG = 'drizzle.config.ts';
6
+ const ASTRO_CONFIG = 'astro.config.mjs';
7
+ const SCRIPTS = [
8
+ { name: 'db:generate', reason: 'le migrazioni si generano dallo schema con quel nome' },
9
+ { name: 'db:migrate', reason: 'le migrazioni si applicano con quel nome, in CI e al deploy' },
10
+ ];
11
+ const CI_INPUTS = {
12
+ workflow: '.github/workflows/ci.yml',
13
+ action: 'ci',
14
+ inputs: [
15
+ { name: 'migrate', reason: 'il branch di test non si migra prima dei test' },
16
+ {
17
+ name: 'database-url',
18
+ reason: 'i test di integrazione si saltano',
19
+ notFrom: [
20
+ { secret: 'PRODUCTION_DATABASE_URL', reason: 'la CI migrerebbe la produzione con le migrazioni delle PR' },
21
+ {
22
+ secret: 'TEST_DATABASE_URL',
23
+ reason: 'le corse parallele migrerebbero lo stesso branch, invece di un branch Neon per corsa',
24
+ },
25
+ ],
26
+ },
27
+ ],
28
+ };
29
+ const DEPLOY_INPUTS = {
30
+ workflow: '.github/workflows/deploy.yml',
31
+ action: 'deploy',
32
+ inputs: [
33
+ { name: 'migrate', reason: 'la produzione parte con lo schema di prima' },
34
+ {
35
+ name: 'database-url',
36
+ reason: "le migrazioni del deploy non hanno l'indirizzo di produzione",
37
+ notFrom: [
38
+ {
39
+ secret: 'TEST_DATABASE_URL',
40
+ reason: 'le migrazioni girerebbero sul branch di test, e la produzione partirebbe con lo schema di prima',
41
+ },
42
+ ],
43
+ },
44
+ ],
45
+ };
46
+ function databaseGaps(files, { scripts = {} }) {
47
+ const gaps = SCRIPTS.filter(({ name }) => scripts[name] === undefined).map(({ name, reason }) => ({
48
+ path: MANIFEST,
49
+ message: `script \`${name}\` assente: ${reason}`,
50
+ }));
51
+ if (files.read(DRIZZLE_CONFIG) === undefined) {
52
+ gaps.push({ path: DRIZZLE_CONFIG, message: 'manca: drizzle-kit legge da lì la sua configurazione' });
53
+ }
54
+ return [...gaps, ...actionInputGaps(files, CI_INPUTS), ...actionInputGaps(files, DEPLOY_INPUTS)];
55
+ }
56
+ function secretGap(key, field) {
57
+ if (field === undefined) {
58
+ return `\`${key}\` non è in \`env.schema\`: va dichiarata con \`context: 'server'\` e \`access: 'secret'\``;
59
+ }
60
+ if (field.context === 'server' && field.access === 'secret')
61
+ return undefined;
62
+ return `\`${key}\` in \`env.schema\` non è \`context: 'server'\` con \`access: 'secret'\`: una variabile pubblica Astro la scrive nel codice della build`;
63
+ }
64
+ function secretGaps(source, keys) {
65
+ if (keys.length === 0)
66
+ return [];
67
+ if (source === undefined)
68
+ return [{ path: ASTRO_CONFIG, message: 'manca: lo schema delle variabili, `env.schema`, sta lì' }];
69
+ const fields = new Map(envSchema(source).map((field) => [field.key, field]));
70
+ return keys.flatMap((key) => {
71
+ const message = secretGap(key, fields.get(key));
72
+ return message === undefined ? [] : [{ path: ASTRO_CONFIG, message }];
73
+ });
74
+ }
75
+ // TODO: dalla 0.13.0 i requisiti del database e dell'autenticazione sono errori di doctor: togli `severity`.
76
+ const asWarning = (gap) => ({ ...gap, severity: 'warning' });
77
+ export function databaseAuthGaps(files, manifest, on) {
78
+ const database = on.includes('database') ? databaseGaps(files, manifest) : [];
79
+ const secrets = secretGaps(files.read(ASTRO_CONFIG), on.map((name) => DEPENDENCY_FEATURES[name].secret));
80
+ return [...database, ...secrets].map(asWarning);
81
+ }
@@ -0,0 +1,8 @@
1
+ export interface EnvField {
2
+ key: string;
3
+ type: string;
4
+ context: string | undefined;
5
+ access: string | undefined;
6
+ constrained: boolean;
7
+ }
8
+ export declare function envSchema(source: string): EnvField[];
@@ -0,0 +1,37 @@
1
+ import { Node, Project, SyntaxKind } from 'ts-morph';
2
+ const ENV_FIELD = /^envField\.(\w+)$/;
3
+ // Le validazioni che la guida di Astro elenca per envField.string, oltre al tipo.
4
+ const CONSTRAINTS = ['max', 'min', 'length', 'url', 'includes', 'startsWith', 'endsWith'];
5
+ const constrained = (options) => Node.isObjectLiteralExpression(options) && CONSTRAINTS.some((name) => options.getProperty(name) !== undefined);
6
+ const keyOf = (property) => {
7
+ const name = property.getNameNode();
8
+ return Node.isStringLiteral(name) ? name.getLiteralValue() : name.getText();
9
+ };
10
+ function literal(options, name) {
11
+ if (!Node.isObjectLiteralExpression(options))
12
+ return undefined;
13
+ const property = options.getProperty(name);
14
+ const value = Node.isPropertyAssignment(property) ? property.getInitializer() : undefined;
15
+ return Node.isStringLiteral(value) ? value.getLiteralValue() : undefined;
16
+ }
17
+ // Il sorgente si legge invece di importarlo: astro.config.mjs importa moduli senza estensione, che
18
+ // risolve Vite e che Node rifiuta con ERR_MODULE_NOT_FOUND.
19
+ export function envSchema(source) {
20
+ const file = new Project({ useInMemoryFileSystem: true }).createSourceFile('astro.config.mjs', source);
21
+ return file.getDescendantsOfKind(SyntaxKind.CallExpression).flatMap((call) => {
22
+ const property = call.getParent();
23
+ const type = ENV_FIELD.exec(call.getExpression().getText())?.[1];
24
+ if (type === undefined || !Node.isPropertyAssignment(property))
25
+ return [];
26
+ const [options] = call.getArguments();
27
+ return [
28
+ {
29
+ key: keyOf(property),
30
+ type,
31
+ context: literal(options, 'context'),
32
+ access: literal(options, 'access'),
33
+ constrained: constrained(options),
34
+ },
35
+ ];
36
+ });
37
+ }
@@ -0,0 +1,30 @@
1
+ export type LinkTarget = {
2
+ kind: 'placeholder' | 'anchor' | 'scheme' | 'external';
3
+ } | {
4
+ kind: 'internal';
5
+ path: string;
6
+ };
7
+ export type PageLinks = {
8
+ route: string;
9
+ hrefs: readonly string[];
10
+ };
11
+ export type LinkReport = {
12
+ broken: {
13
+ destination: string;
14
+ pages: string[];
15
+ }[];
16
+ internal: number;
17
+ onDemand: number;
18
+ outside: {
19
+ external: number;
20
+ scheme: number;
21
+ anchor: number;
22
+ };
23
+ };
24
+ export declare function htmlLinks(html: string): string[];
25
+ export declare const siteOrigin: (siteUrl: string | undefined) => string;
26
+ export declare function linkTarget(href: string, page: URL): LinkTarget;
27
+ export declare function servedPaths(files: readonly string[]): Set<string>;
28
+ export declare function servedBy(path: string, served: ReadonlySet<string>, onDemand: readonly RegExp[]): 'build' | 'on-demand' | undefined;
29
+ export declare function linkReport(pages: readonly PageLinks[], origin: string, served: ReadonlySet<string>, onDemand: readonly RegExp[]): LinkReport;
30
+ export declare const brokenLines: ({ broken }: LinkReport) => string[];