@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
@@ -0,0 +1,24 @@
1
+ import { peerRange, withinRange } from './versions.js';
2
+ // Strumenti che i preset e le action danno per scontati: senza, il progetto sembra allineato e poi
3
+ // il primo commit o il primo job li cerca e non li trova.
4
+ const REQUIRED = [
5
+ { name: '@biomejs/biome', reason: 'il progetto estende `@elia97/officina/biome`' },
6
+ { name: 'lefthook', reason: 'il preset dei git hook è suo, e `prepare` lo installa' },
7
+ { name: '@commitlint/cli', reason: 'il preset di lefthook lo lancia su ogni messaggio di commit' },
8
+ { name: '@commitlint/config-conventional', reason: 'è la configurazione che commitlint estende' },
9
+ { name: 'fallow', reason: '`check:deadcode` e `check:health` lo lanciano' },
10
+ ];
11
+ const BIOME = '@biomejs/biome';
12
+ const MANIFEST = 'package.json';
13
+ // Il preset usa una regola `nursery`, che fra una major e l'altra di Biome cambia nome o sparisce:
14
+ // l'intervallo che regge lo dichiara il pacchetto, in un posto solo.
15
+ function biomeRangeGap(declared) {
16
+ const range = peerRange(BIOME);
17
+ return withinRange(declared, range) ? undefined : `\`${BIOME}\` è \`${declared}\`: il preset regge \`${range}\``;
18
+ }
19
+ export function toolingGaps({ devDependencies = {} }) {
20
+ const gaps = REQUIRED.filter(({ name }) => devDependencies[name] === undefined).map(({ name, reason }) => ({ path: MANIFEST, message: `\`${name}\` non è fra le devDependencies: ${reason}` }));
21
+ const biome = devDependencies[BIOME];
22
+ const range = biome === undefined ? undefined : biomeRangeGap(biome);
23
+ return range === undefined ? gaps : [...gaps, { path: MANIFEST, message: range }];
24
+ }
@@ -0,0 +1,11 @@
1
+ export type Check = (value: unknown, path: string) => string[];
2
+ export declare function typeName(value: unknown): string;
3
+ export declare const problem: (path: string, expected: string, value: unknown) => string;
4
+ export declare const aString: Check;
5
+ export declare const aNumber: Check;
6
+ export declare const aFunction: Check;
7
+ export declare const aStringOrNull: Check;
8
+ export declare const anArrayOf: (item: Check) => Check;
9
+ export declare const aRecordOf: (item: Check) => Check;
10
+ export declare const oneOf: (expected: string, allowed: readonly unknown[]) => Check;
11
+ export declare const aShape: (fields: Record<string, Check>) => Check;
@@ -0,0 +1,40 @@
1
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
2
+ const NAMES = new Map([
3
+ ['string', 'una stringa'],
4
+ ['number', 'un numero'],
5
+ ['boolean', 'un booleano'],
6
+ ['function', 'una funzione'],
7
+ ['undefined', 'niente'],
8
+ ]);
9
+ export function typeName(value) {
10
+ if (value === null)
11
+ return 'null';
12
+ if (Array.isArray(value))
13
+ return 'un array';
14
+ return NAMES.get(typeof value) ?? 'un oggetto';
15
+ }
16
+ export const problem = (path, expected, value) => `${path}: atteso ${expected}, ricevuto ${typeName(value)}`;
17
+ export const aString = (value, path) => (typeof value === 'string' ? [] : [problem(path, 'una stringa', value)]);
18
+ export const aNumber = (value, path) => (typeof value === 'number' ? [] : [problem(path, 'un numero', value)]);
19
+ export const aFunction = (value, path) => typeof value === 'function' ? [] : [problem(path, 'una funzione', value)];
20
+ export const aStringOrNull = (value, path) => typeof value === 'string' || value === null ? [] : [problem(path, 'una stringa oppure null', value)];
21
+ export const anArrayOf = (item) => (value, path) => Array.isArray(value)
22
+ ? value.flatMap((entry, index) => item(entry, `${path}[${index}]`))
23
+ : [problem(path, 'un array', value)];
24
+ export const aRecordOf = (item) => (value, path) => isRecord(value)
25
+ ? Object.entries(value).flatMap(([key, entry]) => item(entry, `${path}.${key}`))
26
+ : [problem(path, 'un oggetto', value)];
27
+ export const oneOf = (expected, allowed) => (value, path) => allowed.includes(value) ? [] : [problem(path, expected, value)];
28
+ // Una voce non dichiarata è un errore: `sitUrl` al posto di `siteUrl` non deve passare in
29
+ // silenzio, che è il modo esatto in cui una configurazione smette di dire qualcosa.
30
+ export const aShape = (fields) => (value, path) => {
31
+ if (!isRecord(value))
32
+ return [problem(path, 'un oggetto', value)];
33
+ const prefix = path === '' ? '' : `${path}.`;
34
+ return Object.entries(value).flatMap(([key, entry]) => {
35
+ const check = fields[key];
36
+ if (check === undefined)
37
+ return [`${prefix}${key}: voce sconosciuta`];
38
+ return entry === undefined ? [] : check(entry, `${prefix}${key}`);
39
+ });
40
+ };
@@ -0,0 +1,5 @@
1
+ export declare const packageVersion: () => string;
2
+ /** L'intervallo che il pacchetto dichiara di reggere: sta in un posto solo, le sue peerDependencies. */
3
+ export declare const peerRange: (name: string) => string;
4
+ /** Vero solo se ogni versione che `spec` può installare sta dentro `range`, scritto `>=x.y.z <a.b.c`. */
5
+ export declare function withinRange(spec: string, range: string): boolean;
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ const self = () => JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
4
+ export const packageVersion = () => self().version;
5
+ /** L'intervallo che il pacchetto dichiara di reggere: sta in un posto solo, le sue peerDependencies. */
6
+ export const peerRange = (name) => self().peerDependencies?.[name] ?? '';
7
+ const EXACT = /(\d+)\.(\d+)\.(\d+)/;
8
+ function parseVersion(text) {
9
+ const found = EXACT.exec(text);
10
+ return found === null ? undefined : [Number(found[1]), Number(found[2]), Number(found[3])];
11
+ }
12
+ const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
13
+ // Il limite superiore escluso di ciò che lo specificatore può installare: `^` sale fino alla major
14
+ // dopo (fino alla minor dopo, sotto lo zero), `~` fino alla minor dopo, una versione esatta a sé.
15
+ function ceiling(spec, [major, minor, patch]) {
16
+ if (spec.startsWith('^'))
17
+ return major === 0 ? [0, minor + 1, 0] : [major + 1, 0, 0];
18
+ if (spec.startsWith('~'))
19
+ return [major, minor + 1, 0];
20
+ return EXACT.test(spec.replace(/^[=v]/, '')) && !/[<>|*x\s]/.test(spec) ? [major, minor, patch + 1] : undefined;
21
+ }
22
+ function parseBounds(range) {
23
+ const [low = '', high = '', extra] = range.split(' ');
24
+ if (extra !== undefined || !low.startsWith('>=') || !high.startsWith('<'))
25
+ return undefined;
26
+ const min = parseVersion(low);
27
+ const max = parseVersion(high);
28
+ return min === undefined || max === undefined ? undefined : { min, max };
29
+ }
30
+ /** Vero solo se ogni versione che `spec` può installare sta dentro `range`, scritto `>=x.y.z <a.b.c`. */
31
+ export function withinRange(spec, range) {
32
+ const bounds = parseBounds(range);
33
+ const floor = parseVersion(spec);
34
+ if (bounds === undefined || floor === undefined)
35
+ return false;
36
+ const top = ceiling(spec, floor);
37
+ return top === undefined ? false : compare(floor, bounds.min) >= 0 && compare(top, bounds.max) <= 0;
38
+ }
@@ -0,0 +1,6 @@
1
+ import type { ProjectFiles } from './alignment.ts';
2
+ import type { ContractGap } from './contract.ts';
3
+ export declare const mentions: (source: string | undefined, needle: string) => boolean;
4
+ /** Le action del progetto devono stare alla stessa versione del pacchetto installato. */
5
+ export declare function workflowGaps({ read }: ProjectFiles, version: string): ContractGap[];
6
+ export declare function dependabotGaps({ read }: ProjectFiles): ContractGap[];
@@ -0,0 +1,70 @@
1
+ // I workflow restano una ricerca testuale — non c'è un parser YAML nel pacchetto — ma una riga
2
+ // commentata non deve soddisfare niente: è il modo più facile per avere un progetto «a posto»
3
+ // che in CI non lancia nessuno di questi passi.
4
+ const liveLines = (source) => source.split(/\r?\n/).filter((line) => !line.trimStart().startsWith('#'));
5
+ export const mentions = (source, needle) => source !== undefined && liveLines(source).some((line) => line.includes(needle));
6
+ const ACTIONS = [
7
+ { workflow: '.github/workflows/ci.yml', action: 'ci' },
8
+ { workflow: '.github/workflows/ci.yml', action: 'review' },
9
+ { workflow: '.github/workflows/deploy.yml', action: 'deploy' },
10
+ { workflow: '.github/workflows/lighthouse.yml', action: 'lighthouse' },
11
+ ];
12
+ // Un tag di versione o uno SHA intero: `@main` farebbe girare in produzione passi mai collaudati.
13
+ const TAG = /^v\d+\.\d+\.\d+$/;
14
+ const SHA = /^[0-9a-f]{40}$/;
15
+ const COMMENTED_VERSION = /#\s*(v\d+\.\d+\.\d+)/;
16
+ function refOf(source, name) {
17
+ const pattern = new RegExp(`${name}@(\\S+)`);
18
+ for (const line of liveLines(source)) {
19
+ const ref = pattern.exec(line)?.[1];
20
+ if (ref !== undefined)
21
+ return { line, ref };
22
+ }
23
+ return undefined;
24
+ }
25
+ function refGap(line, ref, name, expected) {
26
+ if (TAG.test(ref)) {
27
+ return ref === expected ? undefined : `\`${name}@${ref}\`: il pacchetto installato è la ${expected.slice(1)}`;
28
+ }
29
+ if (!SHA.test(ref))
30
+ return `\`${name}@${ref}\`: il riferimento va fissato a un tag di versione o a uno SHA`;
31
+ const declared = COMMENTED_VERSION.exec(line)?.[1];
32
+ if (declared === undefined) {
33
+ return `\`${name}@${ref.slice(0, 7)}…\`: uno SHA non dice che versione è — scrivila nel commento (\`# ${expected}\`)`;
34
+ }
35
+ return declared === expected
36
+ ? undefined
37
+ : `\`${name}@${ref.slice(0, 7)}… ${declared}\`: il pacchetto installato è la ${expected.slice(1)}`;
38
+ }
39
+ function actionGap(source, action, version) {
40
+ const name = `Elia97/officina/actions/${action}`;
41
+ if (source === undefined)
42
+ return `manca: i suoi passi arrivano da \`${name}\``;
43
+ const found = refOf(source, name);
44
+ return found === undefined ? `non usa \`${name}\`` : refGap(found.line, found.ref, name, `v${version}`);
45
+ }
46
+ /** Le action del progetto devono stare alla stessa versione del pacchetto installato. */
47
+ export function workflowGaps({ read }, version) {
48
+ return ACTIONS.flatMap(({ workflow, action }) => {
49
+ const message = actionGap(read(workflow), action, version);
50
+ return message === undefined ? [] : [{ path: workflow, message }];
51
+ });
52
+ }
53
+ const DEPENDABOT = '.github/dependabot.yml';
54
+ // Senza il gruppo, Dependabot apre due PR — una per il pacchetto npm, una per le action — e il
55
+ // controllo di coerenza qui sopra le boccia entrambe, perché nessuna delle due allinea l'altra.
56
+ const GROUPED = [
57
+ { needle: 'multi-ecosystem-groups', reason: 'il pacchetto e le sue action si alzano in una PR sola' },
58
+ { needle: '@elia97/officina', reason: 'il gruppo deve prendere il pacchetto da npm' },
59
+ { needle: 'Elia97/officina/*', reason: 'il gruppo deve prendere le action da github-actions' },
60
+ ];
61
+ export function dependabotGaps({ read }) {
62
+ const source = read(DEPENDABOT);
63
+ if (source === undefined) {
64
+ return [{ path: DEPENDABOT, message: 'manca: senza, il pacchetto e le sue action si alzano separati' }];
65
+ }
66
+ return GROUPED.filter(({ needle }) => !mentions(source, needle)).map(({ needle, reason }) => ({
67
+ path: DEPENDABOT,
68
+ message: `non nomina \`${needle}\`: ${reason}`,
69
+ }));
70
+ }
@@ -38,7 +38,7 @@ SERVER_PID=''
38
38
  cleanup() {
39
39
  if [[ -n "${SERVER_PID}" ]]; then
40
40
  kill -- "-${SERVER_PID}" 2>/dev/null || kill "${SERVER_PID}" 2>/dev/null || true
41
- pkill -f "serve@14 .*--listen ${PORT}" 2>/dev/null || true
41
+ pkill -f "serve@14.2.6 .*--listen ${PORT}" 2>/dev/null || true
42
42
  fi
43
43
  find . -maxdepth 1 \( -name '*wsl.localhost*' -o -name 'undefined*' \) -exec rm -rf {} + 2>/dev/null || true
44
44
  }
@@ -55,7 +55,7 @@ fi
55
55
  echo "✓ robots.txt: Allow: /"
56
56
 
57
57
  echo "→ avvio del server statico su :${PORT}"
58
- setsid pnpm dlx serve@14 "${OUT_DIR}/client" --listen "${PORT}" --no-clipboard >/dev/null 2>&1 &
58
+ setsid pnpm dlx serve@14.2.6 "${OUT_DIR}/client" --listen "${PORT}" --no-clipboard >/dev/null 2>&1 &
59
59
  SERVER_PID=$!
60
60
 
61
61
  for _ in $(seq 1 60); do
@@ -0,0 +1,2 @@
1
+ export { NON_HTML_ROUTES } from './lib/routes.ts';
2
+ export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.ts';
package/dist/smoke.js ADDED
@@ -0,0 +1,4 @@
1
+ // I pezzi con cui un progetto compone i propri controlli di smoke: `smoke.checks` sostituisce la
2
+ // lista del pacchetto, quindi chi ne toglie uno ricompone la lista da qui.
3
+ export { NON_HTML_ROUTES } from './lib/routes.js';
4
+ export { checkBotIdChallenge, checkCanonicalHost, checkPages, checkSecurityHeaders, checkTrailingSlash, DEFAULT_CHECKS, SECURITY_HEADERS, } from './lib/smoke-production.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elia97/officina",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -13,6 +13,18 @@
13
13
  "types": "./dist/index.d.ts",
14
14
  "default": "./dist/index.js"
15
15
  },
16
+ "./smoke": {
17
+ "types": "./dist/smoke.d.ts",
18
+ "default": "./dist/smoke.js"
19
+ },
20
+ "./bundle": {
21
+ "types": "./dist/bundle.d.ts",
22
+ "default": "./dist/bundle.js"
23
+ },
24
+ "./icons": {
25
+ "types": "./dist/icons.d.ts",
26
+ "default": "./dist/icons.js"
27
+ },
16
28
  "./biome": "./presets/biome.base.json"
17
29
  },
18
30
  "bin": {
@@ -24,11 +36,23 @@
24
36
  "README.md"
25
37
  ],
26
38
  "engines": {
27
- "node": ">=24.0.0"
39
+ "node": ">=24.2.0"
28
40
  },
41
+ "packageManager": "pnpm@11.22.0",
29
42
  "publishConfig": {
30
43
  "access": "public"
31
44
  },
45
+ "scripts": {
46
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates && cp -r src/sh dist/sh",
47
+ "prepack": "pnpm run build",
48
+ "typecheck": "tsc --noEmit",
49
+ "test": "vitest run --coverage",
50
+ "format": "biome check --write .",
51
+ "check:deadcode": "fallow dead-code",
52
+ "check:health": "fallow health --complexity --coverage coverage/coverage-final.json",
53
+ "ci": "biome ci --error-on-warnings . && pnpm run typecheck && pnpm run check:deadcode && pnpm run test && pnpm run check:health",
54
+ "test:pack": "bash scripts/pack-test.sh"
55
+ },
32
56
  "devDependencies": {
33
57
  "@biomejs/biome": "2.5.10",
34
58
  "@types/node": "^24.13.5",
@@ -42,20 +66,12 @@
42
66
  "ts-morph": "^28.0.0"
43
67
  },
44
68
  "peerDependencies": {
69
+ "@biomejs/biome": ">=2.5.10 <3.0.0",
45
70
  "sharp": ">=0.34.0"
46
71
  },
47
72
  "peerDependenciesMeta": {
48
73
  "sharp": {
49
74
  "optional": true
50
75
  }
51
- },
52
- "scripts": {
53
- "build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/gen/templates dist/gen/templates && cp -r src/sh dist/sh",
54
- "typecheck": "tsc --noEmit",
55
- "test": "vitest run --coverage",
56
- "format": "biome check --write .",
57
- "check:deadcode": "fallow dead-code",
58
- "check:health": "fallow health --complexity --coverage coverage/coverage-final.json",
59
- "ci": "biome ci --error-on-warnings . && pnpm run typecheck && pnpm run check:deadcode && pnpm run test && pnpm run check:health"
60
76
  }
61
- }
77
+ }
@@ -1,2 +0,0 @@
1
- import { type Hit } from './cli.ts';
2
- export declare function pinFindings(workflow: string, latest: string): Hit[];
@@ -1,29 +0,0 @@
1
- import { readLines } from './cli.js';
2
- const PIN = /\bvercel@(\d+)\b/;
3
- function pinnedVersions(workflow) {
4
- return readLines(workflow).flatMap(({ n, text }) => {
5
- const major = Number(PIN.exec(text)?.[1]);
6
- return Number.isNaN(major) ? [] : [{ line: n, major }];
7
- });
8
- }
9
- function publishedMajor(version) {
10
- return Number(/^\s*(\d+)\./.exec(version)?.[1]);
11
- }
12
- export function pinFindings(workflow, latest) {
13
- const pins = pinnedVersions(workflow);
14
- if (pins.length === 0)
15
- return [{ line: 1, message: 'nessun `vercel@<major>`: il pin è sparito dal workflow' }];
16
- const majors = [...new Set(pins.map(({ major }) => major))].sort((a, b) => a - b);
17
- if (majors.length > 1)
18
- return pins.map(({ line, major }) => ({
19
- line,
20
- message: `vercel@${major}: il workflow porta major diverse (${majors.join(', ')})`,
21
- }));
22
- const published = publishedMajor(latest);
23
- if (Number.isNaN(published))
24
- return [{ line: 1, message: `versione pubblicata illeggibile: ${JSON.stringify(latest)}` }];
25
- const pinned = Math.max(...pins.map(({ major }) => major));
26
- if (pinned >= published)
27
- return [];
28
- return pins.map(({ line }) => ({ line, message: `vercel@${pinned}: su npm è pubblicata la major ${published}` }));
29
- }