@elia97/officina 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/dist/bin.js +25 -0
- package/dist/checks/comments.js +45 -0
- package/dist/checks/language.js +34 -0
- package/dist/checks/placeholders.js +40 -0
- package/dist/checks/roadmap.js +33 -0
- package/dist/checks/routes.js +27 -0
- package/dist/checks/vercel-cli.js +30 -0
- package/dist/lib/check-comments.js +59 -0
- package/dist/lib/check-language.js +81 -0
- package/dist/lib/check-placeholders.js +56 -0
- package/dist/lib/check-roadmap.js +124 -0
- package/dist/lib/check-routes.js +70 -0
- package/dist/lib/cli.js +56 -0
- package/dist/lib/comment-syntax.js +96 -0
- package/dist/lib/git.js +76 -0
- package/dist/lib/language-words.js +157 -0
- package/dist/lib/past-tense.js +19 -0
- package/dist/lib/vercel-cli-pin.js +29 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elia Zarantonello
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# officina
|
|
2
|
+
|
|
3
|
+
Gli strumenti dei progetti nati dai template del sistema (`vetrina`, poi `ecommerce` e `monorepo`), in un pacchetto solo: `@elia97/officina`. Ogni progetto li installa invece di portarne una copia, che alla prima modifica divergerebbe.
|
|
4
|
+
|
|
5
|
+
Il metodo dice come si lavora e sta nel plugin `metodo`; l'officina è dove stanno gli strumenti che CI, lefthook e Vercel eseguono.
|
|
6
|
+
|
|
7
|
+
## Uso in un progetto
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm add -D @elia97/officina
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"scripts": {
|
|
16
|
+
"check:comments": "officina check comments --strict",
|
|
17
|
+
"check:language": "officina check language",
|
|
18
|
+
"check:routes": "officina check routes",
|
|
19
|
+
"check:roadmap": "officina check roadmap",
|
|
20
|
+
"check:placeholders": "officina check placeholders",
|
|
21
|
+
"check:vercel-cli": "officina check vercel-cli"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
I gate leggono dalla cartella corrente, che deve essere la radice del repository. Opzioni comuni: `--diff`, `--base <ref>`, `--head <ref>`, `--strict`, `--format text|github`.
|
|
27
|
+
|
|
28
|
+
## Sviluppo
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pnpm install
|
|
32
|
+
pnpm test # vitest; i test che leggono dal disco girano in test/fixture-project/
|
|
33
|
+
pnpm typecheck
|
|
34
|
+
pnpm build # tsc → dist/, l'unica cosa che viene pubblicata
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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`.
|
|
38
|
+
|
|
39
|
+
## Pubblicare
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pnpm version <patch|minor|major>
|
|
43
|
+
pnpm publish # prepublishOnly lancia typecheck, test e build
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Cosa arriverà
|
|
47
|
+
|
|
48
|
+
I generatori (`officina gen …`) con i loro template, poi bundle budget, smoke di produzione, lighthouse, verifica analytics e icone.
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
// Un modulo per gate, caricato solo quando serve: `officina check routes` non paga il costo
|
|
4
|
+
// degli altri cinque.
|
|
5
|
+
const CHECKS = {
|
|
6
|
+
comments: () => import('./checks/comments.js'),
|
|
7
|
+
language: () => import('./checks/language.js'),
|
|
8
|
+
placeholders: () => import('./checks/placeholders.js'),
|
|
9
|
+
roadmap: () => import('./checks/roadmap.js'),
|
|
10
|
+
routes: () => import('./checks/routes.js'),
|
|
11
|
+
'vercel-cli': () => import('./checks/vercel-cli.js'),
|
|
12
|
+
};
|
|
13
|
+
const USAGE = `Uso: officina check <gate> [opzioni]\n\nGate: ${Object.keys(CHECKS).join(', ')}\nOpzioni comuni: --diff, --base <ref>, --head <ref>, --strict, --format text|github\n`;
|
|
14
|
+
export async function run(argv) {
|
|
15
|
+
const [group, name, ...rest] = argv;
|
|
16
|
+
const load = group === 'check' && name !== undefined ? CHECKS[name] : undefined;
|
|
17
|
+
if (!load) {
|
|
18
|
+
console.error(USAGE);
|
|
19
|
+
return 2;
|
|
20
|
+
}
|
|
21
|
+
const { main } = await load();
|
|
22
|
+
return main(rest);
|
|
23
|
+
}
|
|
24
|
+
if (import.meta.main)
|
|
25
|
+
process.exit(await run(process.argv.slice(2)));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { isNoisy, report, SCANNED } from '../lib/check-comments.js';
|
|
5
|
+
import { cliOptions, exitCode, printFindings, readLines, scopedPaths } from '../lib/cli.js';
|
|
6
|
+
// Esporta main ed esegue solo se lanciato direttamente: la coverage non segue i sottoprocessi,
|
|
7
|
+
// quindi un gate provato con `node scripts/...` risulterebbe non testato.
|
|
8
|
+
export function main(args) {
|
|
9
|
+
const options = cliOptions(args);
|
|
10
|
+
const paths = scopedPaths(options, (path) => SCANNED.test(path));
|
|
11
|
+
let totalLines = 0;
|
|
12
|
+
let totalComments = 0;
|
|
13
|
+
const findings = [];
|
|
14
|
+
for (const path of paths) {
|
|
15
|
+
const result = report(path, readLines(readFileSync(path, 'utf8')));
|
|
16
|
+
totalLines += result.total;
|
|
17
|
+
totalComments += result.comments;
|
|
18
|
+
findings.push(...result.findings.map((f) => ({
|
|
19
|
+
path,
|
|
20
|
+
line: f.line,
|
|
21
|
+
message: f.message,
|
|
22
|
+
severity: 'error',
|
|
23
|
+
})));
|
|
24
|
+
if (isNoisy(result))
|
|
25
|
+
findings.push({
|
|
26
|
+
path,
|
|
27
|
+
message: `${result.comments}/${result.total} righe sono commento`,
|
|
28
|
+
severity: 'warning',
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const ratio = totalLines === 0 ? '0.0' : ((totalComments / totalLines) * 100).toFixed(1);
|
|
32
|
+
const scope = options.diff ? ' (solo file toccati dal branch)' : '';
|
|
33
|
+
console.log(`\ncheck:comments — ${totalComments}/${totalLines} righe sono commento (${ratio}%)${scope}\n`);
|
|
34
|
+
printFindings(findings, options.format);
|
|
35
|
+
if (findings.length === 0) {
|
|
36
|
+
console.log(' Niente sopra la soglia.\n');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.log('\n Il default è non commentare: solo i casi particolari, al presente, sul codice\n' +
|
|
40
|
+
" com'è adesso. Casi e forma in metodo.md, nel plugin metodo.\n");
|
|
41
|
+
}
|
|
42
|
+
return exitCode(findings, options.strict);
|
|
43
|
+
}
|
|
44
|
+
if (import.meta.main)
|
|
45
|
+
process.exit(main());
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { findingsFor, isScanned } from '../lib/check-language.js';
|
|
5
|
+
import { cliOptions, exitCode, printFindings, scopedPaths } from '../lib/cli.js';
|
|
6
|
+
// Esporta main ed esegue solo se lanciato direttamente: la coverage non segue i sottoprocessi,
|
|
7
|
+
// quindi un gate provato con `node scripts/...` risulterebbe non testato.
|
|
8
|
+
export function main(args) {
|
|
9
|
+
const options = cliOptions(args);
|
|
10
|
+
const paths = scopedPaths(options, isScanned);
|
|
11
|
+
const findings = [];
|
|
12
|
+
for (const path of paths) {
|
|
13
|
+
for (const f of findingsFor(path, readFileSync(path, 'utf8')))
|
|
14
|
+
findings.push({
|
|
15
|
+
path,
|
|
16
|
+
line: f.line,
|
|
17
|
+
message: f.message,
|
|
18
|
+
severity: 'error',
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
const scope = options.diff ? ' (solo file toccati dal branch)' : '';
|
|
22
|
+
console.log(`\ncheck:language — ${paths.length} file con prosa${scope}\n`);
|
|
23
|
+
printFindings(findings, options.format);
|
|
24
|
+
if (findings.length === 0) {
|
|
25
|
+
console.log(' Tutto in italiano.\n');
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.log('\n Codice e identificatori in inglese, commenti e documentazione in italiano.');
|
|
29
|
+
console.log(' Le fonti del cliente sotto docs/sources/ non vengono guardate.\n');
|
|
30
|
+
}
|
|
31
|
+
return exitCode(findings, options.strict);
|
|
32
|
+
}
|
|
33
|
+
if (import.meta.main)
|
|
34
|
+
process.exit(main());
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { envFindings, placeholderSources, sourceFindings } from '../lib/check-placeholders.js';
|
|
6
|
+
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
7
|
+
function sourceScan(paths) {
|
|
8
|
+
return paths.flatMap((path) => sourceFindings(readFileSync(path, 'utf8')).map((hit) => ({ path, ...hit, severity: 'error' })));
|
|
9
|
+
}
|
|
10
|
+
function envScan(path) {
|
|
11
|
+
if (!existsSync(path))
|
|
12
|
+
return [{ path, message: "file dell'ambiente assente: lo scrive vercel pull", severity: 'error' }];
|
|
13
|
+
return envFindings(readFileSync(path, 'utf8')).map((hit) => ({ path, ...hit, severity: 'error' }));
|
|
14
|
+
}
|
|
15
|
+
function scan(env) {
|
|
16
|
+
if (env !== undefined) {
|
|
17
|
+
return {
|
|
18
|
+
scope: `ambiente in ${env}`,
|
|
19
|
+
findings: envScan(env),
|
|
20
|
+
fix: 'docs/guides/deploy-ops.md § Checklist per il go-live',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const paths = placeholderSources();
|
|
24
|
+
return { scope: `${paths.length} sorgenti`, findings: sourceScan(paths), fix: 'README.md § Cosa tocca il rebranding' };
|
|
25
|
+
}
|
|
26
|
+
export function main(args = process.argv.slice(2)) {
|
|
27
|
+
const { env } = parseArgs({ args, options: { env: { type: 'string' } } }).values;
|
|
28
|
+
const { scope, findings, fix } = scan(env);
|
|
29
|
+
console.log(`\ncheck:placeholders — ${scope}\n`);
|
|
30
|
+
printFindings(findings, cliOptions([]).format);
|
|
31
|
+
if (findings.length === 0) {
|
|
32
|
+
console.log(' Nessun segnaposto del template.\n');
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log(`\n Da sostituire prima del deploy: ${fix}.\n`);
|
|
36
|
+
}
|
|
37
|
+
return exitCode(findings, false);
|
|
38
|
+
}
|
|
39
|
+
if (import.meta.main)
|
|
40
|
+
process.exit(main());
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { findingsFor } from '../lib/check-roadmap.js';
|
|
5
|
+
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
6
|
+
const ROADMAP = 'docs/ROADMAP.md';
|
|
7
|
+
// Esporta main ed esegue solo se lanciato direttamente: la copertura non segue i sottoprocessi,
|
|
8
|
+
// quindi un gate provato con `node scripts/...` risulterebbe non testato.
|
|
9
|
+
export function main(args) {
|
|
10
|
+
const options = cliOptions(args);
|
|
11
|
+
// Un progetto appena creato dal template può non averla ancora scritta.
|
|
12
|
+
if (!existsSync(ROADMAP)) {
|
|
13
|
+
console.log(`\ncheck:roadmap — ${ROADMAP} non c'è ancora\n`);
|
|
14
|
+
return 0;
|
|
15
|
+
}
|
|
16
|
+
const findings = findingsFor(readFileSync(ROADMAP, 'utf8')).map((f) => ({
|
|
17
|
+
path: ROADMAP,
|
|
18
|
+
line: f.line,
|
|
19
|
+
message: f.message,
|
|
20
|
+
severity: 'error',
|
|
21
|
+
}));
|
|
22
|
+
console.log(`\ncheck:roadmap — le giornate dette in due posti\n`);
|
|
23
|
+
printFindings(findings, options.format);
|
|
24
|
+
if (findings.length === 0) {
|
|
25
|
+
console.log(' Tabella e sezioni concordano.\n');
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
console.log('\n Le giornate sono la fonte contrattuale: due numeri diversi sono uno sbagliato.\n');
|
|
29
|
+
}
|
|
30
|
+
return exitCode(findings, options.strict);
|
|
31
|
+
}
|
|
32
|
+
if (import.meta.main)
|
|
33
|
+
process.exit(main());
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { findingsFor, isScanned } from '../lib/check-routes.js';
|
|
5
|
+
import { cliOptions, exitCode, printFindings, scopedPaths } from '../lib/cli.js';
|
|
6
|
+
// Esporta main ed esegue solo se lanciato direttamente: la copertura non segue i sottoprocessi,
|
|
7
|
+
// quindi un gate provato con `node scripts/...` risulterebbe non testato.
|
|
8
|
+
export function main(args) {
|
|
9
|
+
const options = cliOptions(args);
|
|
10
|
+
const paths = scopedPaths(options, isScanned);
|
|
11
|
+
const findings = [];
|
|
12
|
+
for (const path of paths)
|
|
13
|
+
for (const f of findingsFor(path, readFileSync(path, 'utf8')))
|
|
14
|
+
findings.push({ path, line: f.line, message: f.message, severity: 'error' });
|
|
15
|
+
const scope = options.diff ? ' (solo file toccati dal branch)' : '';
|
|
16
|
+
console.log(`\ncheck:routes — ${paths.length} documenti${scope}\n`);
|
|
17
|
+
printFindings(findings, options.format);
|
|
18
|
+
if (findings.length === 0) {
|
|
19
|
+
console.log(' Ogni rimando risolve.\n');
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.log('\n Un rimando morto non rompe niente: lo si scopre seguendolo, mesi dopo.\n');
|
|
23
|
+
}
|
|
24
|
+
return exitCode(findings, options.strict);
|
|
25
|
+
}
|
|
26
|
+
if (import.meta.main)
|
|
27
|
+
process.exit(main());
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { cliOptions, exitCode, printFindings } from '../lib/cli.js';
|
|
5
|
+
import { pinFindings } from '../lib/vercel-cli-pin.js';
|
|
6
|
+
const WORKFLOW = '.github/workflows/deploy.yml';
|
|
7
|
+
// Il documento `latest` porta la sola versione pubblicata; quello del pacchetto intero pesa 8 MB.
|
|
8
|
+
const REGISTRY = 'https://registry.npmjs.org/vercel/latest';
|
|
9
|
+
const TIMEOUT_MS = 15_000;
|
|
10
|
+
export async function main() {
|
|
11
|
+
const response = await fetch(REGISTRY, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
console.error(`✗ registry npm illeggibile: HTTP ${response.status}`);
|
|
14
|
+
return 1;
|
|
15
|
+
}
|
|
16
|
+
const { version } = (await response.json());
|
|
17
|
+
const findings = pinFindings(readFileSync(WORKFLOW, 'utf8'), version).map((hit) => ({
|
|
18
|
+
path: WORKFLOW,
|
|
19
|
+
...hit,
|
|
20
|
+
severity: 'error',
|
|
21
|
+
}));
|
|
22
|
+
console.log(`\ncheck:vercel-cli — il pin contro la ${version} pubblicata su npm\n`);
|
|
23
|
+
printFindings(findings, cliOptions([]).format);
|
|
24
|
+
console.log(findings.length === 0
|
|
25
|
+
? ' Il pin è sulla major pubblicata.\n'
|
|
26
|
+
: `\n Alza il pin in ${WORKFLOW} e allinea la CLI locale alla stessa major.\n`);
|
|
27
|
+
return exitCode(findings, false);
|
|
28
|
+
}
|
|
29
|
+
if (import.meta.main)
|
|
30
|
+
process.exit(await main());
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { commentBlocks, styleOf } from './comment-syntax.js';
|
|
2
|
+
import { PAST_TENSE } from './past-tense.js';
|
|
3
|
+
const MAX_PROSE_LINES = 2;
|
|
4
|
+
const NOISY_RATIO = 0.15;
|
|
5
|
+
const MIN_LINES_FOR_RATIO = 40;
|
|
6
|
+
// Direttive per strumenti: non sono prosa, non contano nella lunghezza del blocco né per il
|
|
7
|
+
// tempo verbale.
|
|
8
|
+
const KEEP = /biome-ignore|@ts-|\/\/\/\s*<reference|@vitest-environment|@vite-ignore|fallow-ignore|v8 ignore|c8 ignore|istanbul ignore|#__PURE__|SPDX-License-Identifier|TODO|FIXME|\[HARD\]|@public|@internal|@deprecated|^#!/;
|
|
9
|
+
// Righe fatte solo di delimitatori: aprono o chiudono un blocco senza dire niente.
|
|
10
|
+
const DELIMITER_ONLY = /^(?:\/\*+|\*+\/|\*|\{\/\*+|\*+\/\}|<!--|-->)$/;
|
|
11
|
+
// Fuori: i file in cui il commento *è* il contenuto — in `.env.example` e `.npmrc` ogni riga
|
|
12
|
+
// di prosa documenta la chiave sotto di sé.
|
|
13
|
+
export const SCANNED = /\.(ts|tsx|astro|mjs|cjs|js|css|ya?ml|sh)$/;
|
|
14
|
+
const isProse = (line) => !DELIMITER_ONLY.test(line.text.trim()) && !KEEP.test(line.text);
|
|
15
|
+
// Un marcatore esenta la propria riga, mai il blocco: un solo `[HARD]` in mezzo comprerebbe
|
|
16
|
+
// al commento lunghezza illimitata.
|
|
17
|
+
function blockFinding(block) {
|
|
18
|
+
if (block.trailing)
|
|
19
|
+
return null;
|
|
20
|
+
const [first] = block.lines;
|
|
21
|
+
const prose = block.lines.filter(isProse).length;
|
|
22
|
+
if (!first || prose <= MAX_PROSE_LINES)
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
line: first.n,
|
|
26
|
+
message: `blocco di ${prose} righe di prosa (max ${MAX_PROSE_LINES})`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function pastTenseFinding(line) {
|
|
30
|
+
if (KEEP.test(line.text))
|
|
31
|
+
return null;
|
|
32
|
+
if (!PAST_TENSE.some((re) => re.test(line.text)))
|
|
33
|
+
return null;
|
|
34
|
+
return {
|
|
35
|
+
line: line.n,
|
|
36
|
+
message: `racconta il cambiamento: ${line.text.trim().slice(0, 60)}`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function report(file, lines) {
|
|
40
|
+
const findings = [];
|
|
41
|
+
let comments = 0;
|
|
42
|
+
for (const block of commentBlocks(lines, styleOf(file))) {
|
|
43
|
+
if (!block.trailing)
|
|
44
|
+
comments += block.lines.length;
|
|
45
|
+
const long = blockFinding(block);
|
|
46
|
+
if (long)
|
|
47
|
+
findings.push(long);
|
|
48
|
+
for (const line of block.lines) {
|
|
49
|
+
const narrated = pastTenseFinding(line);
|
|
50
|
+
if (narrated)
|
|
51
|
+
findings.push(narrated);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
findings.sort((a, b) => a.line - b.line);
|
|
55
|
+
return { findings, comments, total: lines.length };
|
|
56
|
+
}
|
|
57
|
+
export function isNoisy({ comments, total }) {
|
|
58
|
+
return total >= MIN_LINES_FOR_RATIO && comments / total > NOISY_RATIO;
|
|
59
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { commentBlocks, styleOf } from './comment-syntax.js';
|
|
2
|
+
import { ENGLISH, ITALIAN } from './language-words.js';
|
|
3
|
+
const CODE = /^(?:(?:src|scripts|test|\.claude\/hooks|\.github)\/.*|[^/]+)\.(?:ts|tsx|astro|mjs|cjs|js|css|ya?ml|jsonc)$/;
|
|
4
|
+
// Fuori le fonti del cliente (materiale altrui) e i changelog, generati in inglese.
|
|
5
|
+
const DOCS = /^(?!docs\/sources\/)(?!(?:.*\/)?CHANGELOG\.md$).*\.md$/;
|
|
6
|
+
// I dizionari sono copy per l'utente nelle lingue del sito: en e de leggono inglese e tedesco
|
|
7
|
+
// per definizione, ed è il solo posto sotto CODE dove non deve leggersi italiano.
|
|
8
|
+
const LOCALIZED = /^src\/i18n\/strings\//;
|
|
9
|
+
// Sotto questa soglia il campione non dice niente: meglio indeciso che indovinato.
|
|
10
|
+
const MIN_HITS = 4;
|
|
11
|
+
// Un documento italiano che cita frasi inglesi non deve scattare: serve una maggioranza netta.
|
|
12
|
+
const ENGLISH_SHARE = 0.6;
|
|
13
|
+
// Inglese per costruzione, da togliere prima di contare: frontmatter, blocchi e span di
|
|
14
|
+
// codice, URL, destinazioni dei link, riferimenti puntati (`this.cache`), chiamate, direttive.
|
|
15
|
+
const NOISE = [
|
|
16
|
+
/^---\r?\n[\s\S]*?\r?\n---/,
|
|
17
|
+
/```[\s\S]*?```/g,
|
|
18
|
+
/`[^`\n]*`/g,
|
|
19
|
+
/https?:\/\/\S+/g,
|
|
20
|
+
/\]\([^)]*\)/g,
|
|
21
|
+
/[\w$]+(?:\.[\w$]+)+/g,
|
|
22
|
+
/[\w$]+\([^)]*\)/g,
|
|
23
|
+
/@[\w-]+/g,
|
|
24
|
+
];
|
|
25
|
+
export function isScanned(path) {
|
|
26
|
+
if (LOCALIZED.test(path))
|
|
27
|
+
return false;
|
|
28
|
+
return CODE.test(path) || DOCS.test(path);
|
|
29
|
+
}
|
|
30
|
+
function clean(prose) {
|
|
31
|
+
return NOISE.reduce((text, re) => text.replace(re, ' '), prose);
|
|
32
|
+
}
|
|
33
|
+
export function tally(prose) {
|
|
34
|
+
const text = clean(prose);
|
|
35
|
+
return {
|
|
36
|
+
italian: (text.match(ITALIAN) ?? []).length,
|
|
37
|
+
english: (text.match(ENGLISH) ?? []).length,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function classify({ italian, english }, minHits = MIN_HITS) {
|
|
41
|
+
const hits = italian + english;
|
|
42
|
+
// Le liste di language-words.ts portano solo parole di una lingua sola: una inglese senza
|
|
43
|
+
// nessuna italiana decide anche un commento troppo corto per raggiungere MIN_HITS.
|
|
44
|
+
if (english > 0 && italian === 0)
|
|
45
|
+
return 'english';
|
|
46
|
+
if (hits < minHits)
|
|
47
|
+
return 'undecided';
|
|
48
|
+
return english / hits >= ENGLISH_SHARE ? 'english' : 'italian';
|
|
49
|
+
}
|
|
50
|
+
const describe = ({ english, italian }, sample) => `legge come inglese (en ${english} / it ${italian}): ${sample.replace(/\s+/g, ' ').trim().slice(0, 60)}`;
|
|
51
|
+
const toLines = (source) => source.split('\n').map((text, i) => ({ n: i + 1, text }));
|
|
52
|
+
/** In un sorgente la prosa sta solo nei commenti: il codice è inglese per costruzione. */
|
|
53
|
+
export function findingsFor(path, source) {
|
|
54
|
+
if (!CODE.test(path)) {
|
|
55
|
+
const counts = tally(source);
|
|
56
|
+
return classify(counts) === 'english' ? [{ line: 1, message: describe(counts, source) }] : [];
|
|
57
|
+
}
|
|
58
|
+
const blocks = commentBlocks(toLines(source), styleOf(path));
|
|
59
|
+
const findings = [];
|
|
60
|
+
const total = { italian: 0, english: 0 };
|
|
61
|
+
for (const block of blocks) {
|
|
62
|
+
const [first] = block.lines;
|
|
63
|
+
const text = block.lines.map((line) => line.text).join('\n');
|
|
64
|
+
const counts = tally(text);
|
|
65
|
+
total.italian += counts.italian;
|
|
66
|
+
total.english += counts.english;
|
|
67
|
+
if (first && classify(counts) === 'english')
|
|
68
|
+
findings.push({
|
|
69
|
+
line: first.n,
|
|
70
|
+
message: describe(counts, text),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
// Molti commenti brevi in inglese non fanno scattare nessun blocco da soli, ma insieme sì.
|
|
74
|
+
const firstLine = blocks[0]?.lines[0];
|
|
75
|
+
if (findings.length === 0 && classify(total) === 'english' && firstLine)
|
|
76
|
+
findings.push({
|
|
77
|
+
line: firstLine.n,
|
|
78
|
+
message: describe(total, 'nel complesso'),
|
|
79
|
+
});
|
|
80
|
+
return findings;
|
|
81
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parseEnv } from 'node:util';
|
|
4
|
+
import { readLines } from './cli.js';
|
|
5
|
+
const DICTIONARIES = 'src/i18n/strings';
|
|
6
|
+
const CONTACT_ENV_KEYS = ['CONTACT_FROM_EMAIL', 'CONTACT_FROM_NAME', 'CONTACT_TO_EMAIL'];
|
|
7
|
+
const TOKEN = /<[A-Z][A-Z_]{2,}>/;
|
|
8
|
+
const DOMAIN = /\bexample\.com\b/;
|
|
9
|
+
const STRING_LITERAL = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g;
|
|
10
|
+
const COMMENT_ONLY = /^\s*(?:\/\/|\/\*|\*)/;
|
|
11
|
+
const ZERO_POSTAL_CODE = /\bpostalCode:\s*(['"])00000\1/;
|
|
12
|
+
const EMPTY_HREF = /\bhref:\s*(['"])#\1/;
|
|
13
|
+
export function placeholderSources() {
|
|
14
|
+
const dictionaries = readdirSync(DICTIONARIES)
|
|
15
|
+
.filter((name) => name.endsWith('.ts'))
|
|
16
|
+
.sort()
|
|
17
|
+
.map((name) => join(DICTIONARIES, name));
|
|
18
|
+
return ['src/lib/site.ts', 'src/lib/company.ts', ...dictionaries];
|
|
19
|
+
}
|
|
20
|
+
function literalFindings(value) {
|
|
21
|
+
const messages = [];
|
|
22
|
+
const token = value.match(TOKEN);
|
|
23
|
+
if (token)
|
|
24
|
+
messages.push(`segnaposto del template ${token[0]}`);
|
|
25
|
+
if (DOMAIN.test(value))
|
|
26
|
+
messages.push('dominio segnaposto example.com');
|
|
27
|
+
if (/^\+390+$/.test(value.replace(/\s/g, '')))
|
|
28
|
+
messages.push(`telefono segnaposto ${value}`);
|
|
29
|
+
return messages;
|
|
30
|
+
}
|
|
31
|
+
export function sourceFindings(source) {
|
|
32
|
+
return readLines(source).flatMap(({ n, text }) => {
|
|
33
|
+
if (COMMENT_ONLY.test(text))
|
|
34
|
+
return [];
|
|
35
|
+
const messages = [...text.matchAll(STRING_LITERAL)].flatMap((match) => literalFindings(match[0].slice(1, -1)));
|
|
36
|
+
if (ZERO_POSTAL_CODE.test(text))
|
|
37
|
+
messages.push('CAP segnaposto 00000');
|
|
38
|
+
if (EMPTY_HREF.test(text))
|
|
39
|
+
messages.push("profilo senza indirizzo: href: '#'");
|
|
40
|
+
return messages.map((message) => ({ line: n, message }));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function envFindings(content) {
|
|
44
|
+
const env = parseEnv(content);
|
|
45
|
+
const lines = readLines(content);
|
|
46
|
+
return CONTACT_ENV_KEYS.flatMap((key) => {
|
|
47
|
+
const value = env[key]?.trim() ?? '';
|
|
48
|
+
const declaration = lines.find(({ text }) => new RegExp(`^\\s*${key}\\s*=`).test(text));
|
|
49
|
+
const at = declaration ? { line: declaration.n } : {};
|
|
50
|
+
if (value === '')
|
|
51
|
+
return [{ ...at, message: `${key} non è impostata: vale il default di astro.config.mjs` }];
|
|
52
|
+
if (TOKEN.test(value) || DOMAIN.test(value))
|
|
53
|
+
return [{ ...at, message: `${key} porta un segnaposto del template` }];
|
|
54
|
+
return [];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** «1,5» è una giornata e mezza. Una cella vuota non è zero giornate: `Number('')` sarebbe 0. */
|
|
2
|
+
export function parseDays(raw) {
|
|
3
|
+
const text = raw.trim().replace(',', '.');
|
|
4
|
+
return text === '' ? Number.NaN : Number(text);
|
|
5
|
+
}
|
|
6
|
+
const cell = (row) => row.split('|').map((c) => c.trim());
|
|
7
|
+
const ROW = /^\|\s*(\d+)\s*\|/;
|
|
8
|
+
const SUBTOTAL = /^\|\s*\|\s*\*\*(FASE (\d+)[^*]*|TOTALE)\*\*/;
|
|
9
|
+
const SECTION = /^## Milestone (\d+) — (.+)$/;
|
|
10
|
+
const HEADER = /^\*\*Fase (\d+)\*\*\s*·\s*\*\*([\d,]+) gg\*\*/;
|
|
11
|
+
/** Le righe numerate della tabella Status: una per milestone. */
|
|
12
|
+
export function statusRows(source) {
|
|
13
|
+
const rows = [];
|
|
14
|
+
source.split('\n').forEach((line, i) => {
|
|
15
|
+
const match = ROW.exec(line);
|
|
16
|
+
if (!match?.[1])
|
|
17
|
+
return;
|
|
18
|
+
const cells = cell(line);
|
|
19
|
+
const number = Number(match[1]);
|
|
20
|
+
const name = cells[2];
|
|
21
|
+
const phase = Number(cells[3]);
|
|
22
|
+
const days = parseDays(cells[4] ?? '');
|
|
23
|
+
if (!name || Number.isNaN(phase) || Number.isNaN(days))
|
|
24
|
+
return;
|
|
25
|
+
rows.push({ line: i + 1, number, name, phase, days });
|
|
26
|
+
});
|
|
27
|
+
return rows;
|
|
28
|
+
}
|
|
29
|
+
/** Le righe di somma: un subtotale per fase, più il totale. */
|
|
30
|
+
export function subtotals(source) {
|
|
31
|
+
const out = [];
|
|
32
|
+
source.split('\n').forEach((line, i) => {
|
|
33
|
+
const match = SUBTOTAL.exec(line);
|
|
34
|
+
if (!match?.[1])
|
|
35
|
+
return;
|
|
36
|
+
const days = parseDays((cell(line)[4] ?? '').replace(/\*/g, ''));
|
|
37
|
+
if (Number.isNaN(days))
|
|
38
|
+
return;
|
|
39
|
+
out.push({ line: i + 1, label: match[1], phase: match[2] ? Number(match[2]) : null, days });
|
|
40
|
+
});
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
/** Le sezioni `## Milestone N`, con la fase e le giornate della loro testata. */
|
|
44
|
+
export function sections(source) {
|
|
45
|
+
const lines = source.split('\n');
|
|
46
|
+
const out = [];
|
|
47
|
+
lines.forEach((line, i) => {
|
|
48
|
+
const match = SECTION.exec(line);
|
|
49
|
+
if (!match?.[1] || !match[2])
|
|
50
|
+
return;
|
|
51
|
+
// La testata sta nelle righe subito sotto il titolo, dopo il riferimento alla Milestone GitHub.
|
|
52
|
+
const header = lines.slice(i, i + 6).find((l) => HEADER.test(l));
|
|
53
|
+
const parsed = header ? HEADER.exec(header) : null;
|
|
54
|
+
if (!parsed?.[1] || !parsed[2])
|
|
55
|
+
return;
|
|
56
|
+
out.push({
|
|
57
|
+
line: i + 1,
|
|
58
|
+
number: Number(match[1]),
|
|
59
|
+
name: match[2].trim(),
|
|
60
|
+
phase: Number(parsed[1]),
|
|
61
|
+
days: parseDays(parsed[2]),
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
const sum = (values) => Number(values.reduce((a, b) => a + b, 0).toFixed(2));
|
|
67
|
+
/** I subtotali di fase e il totale devono essere la somma delle righe che coprono. */
|
|
68
|
+
function totalsAgree(rows, totals) {
|
|
69
|
+
const findings = [];
|
|
70
|
+
for (const total of totals) {
|
|
71
|
+
const covered = total.phase === null ? rows : rows.filter((r) => r.phase === total.phase);
|
|
72
|
+
const expected = sum(covered.map((r) => r.days));
|
|
73
|
+
if (expected !== total.days)
|
|
74
|
+
findings.push({
|
|
75
|
+
line: total.line,
|
|
76
|
+
message: `${total.label}: la riga dice ${total.days} ma le milestone sommano ${expected}`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return findings;
|
|
80
|
+
}
|
|
81
|
+
/** Ogni riga della tabella ha la sua sezione, e le due dicono le stesse giornate e la stessa fase. */
|
|
82
|
+
function rowsAgree(rows, byNumber) {
|
|
83
|
+
const findings = [];
|
|
84
|
+
for (const row of rows) {
|
|
85
|
+
const section = byNumber.get(row.number);
|
|
86
|
+
if (!section) {
|
|
87
|
+
findings.push({ line: row.line, message: `milestone ${row.number} in tabella ma senza sezione` });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (section.days !== row.days)
|
|
91
|
+
findings.push({
|
|
92
|
+
line: section.line,
|
|
93
|
+
message: `milestone ${row.number}: la tabella dice ${row.days} gg, la sezione ${section.days}`,
|
|
94
|
+
});
|
|
95
|
+
if (section.phase !== row.phase)
|
|
96
|
+
findings.push({
|
|
97
|
+
line: section.line,
|
|
98
|
+
message: `milestone ${row.number}: la tabella la mette in fase ${row.phase}, la sezione in fase ${section.phase}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return findings;
|
|
102
|
+
}
|
|
103
|
+
/** Una sezione che la tabella non elenca è lavoro che nessun totale conta. */
|
|
104
|
+
function sectionsAreListed(rows, byNumber) {
|
|
105
|
+
const numbers = new Set(rows.map((r) => r.number));
|
|
106
|
+
return [...byNumber.values()]
|
|
107
|
+
.filter((section) => !numbers.has(section.number))
|
|
108
|
+
.map((section) => ({
|
|
109
|
+
line: section.line,
|
|
110
|
+
message: `milestone ${section.number} ha una sezione ma non è in tabella`,
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
export function findingsFor(source) {
|
|
114
|
+
const rows = statusRows(source);
|
|
115
|
+
// Una roadmap ancora senza milestone è lo stato normale di un progetto appena nato.
|
|
116
|
+
if (rows.length === 0)
|
|
117
|
+
return [];
|
|
118
|
+
const byNumber = new Map(sections(source).map((s) => [s.number, s]));
|
|
119
|
+
return [
|
|
120
|
+
...totalsAgree(rows, subtotals(source)),
|
|
121
|
+
...rowsAgree(rows, byNumber),
|
|
122
|
+
...sectionsAreListed(rows, byNumber),
|
|
123
|
+
].sort((a, b) => a.line - b.line);
|
|
124
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
const SCANNED = /\.md$/;
|
|
3
|
+
const IGNORED = [
|
|
4
|
+
// Scritti a runtime dai comandi, gitignored per costruzione.
|
|
5
|
+
/^\.claude\/plans\//,
|
|
6
|
+
// Un PDF in docs/ è generato dai .md accanto, mai tracciato.
|
|
7
|
+
/\.pdf$/,
|
|
8
|
+
/^docs\/sources\//,
|
|
9
|
+
];
|
|
10
|
+
const PATH_REFERENCE = /`((?:docs|src|scripts|test|public|\.claude|\.github)\/[A-Za-z0-9._/-]+\.[a-z]+)`/g;
|
|
11
|
+
// La virgola non delimita: i titoli la contengono.
|
|
12
|
+
const SECTION_REFERENCE = /`([A-Za-z0-9._/-]+\.md)`\s*§\s*([^.·|)§]{1,80})/g;
|
|
13
|
+
const isIgnored = (path) => IGNORED.some((re) => re.test(path));
|
|
14
|
+
export const isScanned = (path) => SCANNED.test(path) && !isIgnored(path);
|
|
15
|
+
export function normalizeTitle(raw) {
|
|
16
|
+
return (raw
|
|
17
|
+
.replace(/\*\*/g, '')
|
|
18
|
+
.replace(/`/g, '')
|
|
19
|
+
// Il marcatore non fa parte del nome: «Come si lavora [HARD]» si cita «Come si lavora».
|
|
20
|
+
.replace(/\s*\[[A-Z-]+\]\s*$/, '')
|
|
21
|
+
.replace(/\s+/g, ' ')
|
|
22
|
+
.trim()
|
|
23
|
+
.toLowerCase());
|
|
24
|
+
}
|
|
25
|
+
export function headings(source) {
|
|
26
|
+
return source
|
|
27
|
+
.split('\n')
|
|
28
|
+
.filter((line) => /^#{2,4} /.test(line))
|
|
29
|
+
.map((line) => normalizeTitle(line.replace(/^#+\s*/, '')));
|
|
30
|
+
}
|
|
31
|
+
const resolves = (titles, cited) => titles.some((title) => cited === title || (cited.startsWith(title) && /^[^\p{L}\p{N}]/u.test(cited.slice(title.length))));
|
|
32
|
+
export function resolveTarget(from, cited) {
|
|
33
|
+
if (cited.includes('/'))
|
|
34
|
+
return cited;
|
|
35
|
+
const sibling = `${from.slice(0, from.lastIndexOf('/') + 1)}${cited}`;
|
|
36
|
+
return existsSync(sibling) ? sibling : cited;
|
|
37
|
+
}
|
|
38
|
+
export function findingsFor(path, source) {
|
|
39
|
+
const findings = [];
|
|
40
|
+
// Un titolo va a capo come qualunque prosa: si cerca su testo continuo. La sostituzione è uno a
|
|
41
|
+
// uno, quindi gli offset restano quelli del sorgente e la riga si conta da lì.
|
|
42
|
+
const flat = source.replace(/\n/g, ' ');
|
|
43
|
+
const lineOf = (index) => source.slice(0, index).split('\n').length;
|
|
44
|
+
const missingTargets = new Set();
|
|
45
|
+
for (const match of flat.matchAll(PATH_REFERENCE)) {
|
|
46
|
+
const target = match[1];
|
|
47
|
+
if (!target || isIgnored(target) || target.includes('*') || existsSync(target))
|
|
48
|
+
continue;
|
|
49
|
+
missingTargets.add(target);
|
|
50
|
+
findings.push({ line: lineOf(match.index), message: `percorso che non esiste: ${target}` });
|
|
51
|
+
}
|
|
52
|
+
for (const match of flat.matchAll(SECTION_REFERENCE)) {
|
|
53
|
+
const [, file, title] = match;
|
|
54
|
+
/* v8 ignore next -- i due gruppi sono obbligatori nella regex, ma noUncheckedIndexedAccess pretende la guardia */
|
|
55
|
+
if (!file || !title)
|
|
56
|
+
continue;
|
|
57
|
+
const line = lineOf(match.index);
|
|
58
|
+
const target = resolveTarget(path, file);
|
|
59
|
+
if (isIgnored(target) || missingTargets.has(target))
|
|
60
|
+
continue;
|
|
61
|
+
if (!existsSync(target)) {
|
|
62
|
+
findings.push({ line, message: `sezione in un file che non esiste: ${target}` });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const cited = normalizeTitle(title);
|
|
66
|
+
if (!resolves(headings(readFileSync(target, 'utf8')), cited))
|
|
67
|
+
findings.push({ line, message: `sezione che non esiste: ${target} § ${cited}` });
|
|
68
|
+
}
|
|
69
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
70
|
+
}
|
package/dist/lib/cli.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { changedFiles, trackedAndUntracked } from './git.js';
|
|
5
|
+
export function cliOptions(args = process.argv.slice(2)) {
|
|
6
|
+
const { GITHUB_ACTIONS } = process.env;
|
|
7
|
+
const { values } = parseArgs({
|
|
8
|
+
args,
|
|
9
|
+
options: {
|
|
10
|
+
diff: { type: 'boolean', default: false },
|
|
11
|
+
base: { type: 'string' },
|
|
12
|
+
head: { type: 'string' },
|
|
13
|
+
strict: { type: 'boolean', default: false },
|
|
14
|
+
format: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
default: GITHUB_ACTIONS ? 'github' : 'text',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
return { ...values, diff: values.diff || values.base !== undefined };
|
|
21
|
+
}
|
|
22
|
+
// Un file generato non lo scrive nessuno, e il suo banner chiede di tenerlo fuori da linter e
|
|
23
|
+
// formatter: una correzione ai suoi commenti sparirebbe alla prima rigenerazione.
|
|
24
|
+
export const isGenerated = (path) => /\.gen\.[cm]?tsx?$/.test(path);
|
|
25
|
+
export function scopedPaths(options, isScanned) {
|
|
26
|
+
const candidates = options.diff ? changedFiles({ base: options.base, head: options.head }) : trackedAndUntracked();
|
|
27
|
+
// ls-files elenca anche i file cancellati ma non ancora rimossi dall'indice.
|
|
28
|
+
return candidates
|
|
29
|
+
.filter((path) => isScanned(path) && !isGenerated(path))
|
|
30
|
+
.filter(existsSync)
|
|
31
|
+
.sort();
|
|
32
|
+
}
|
|
33
|
+
// I workflow command di GitHub vogliono %, CR e LF codificati nel messaggio.
|
|
34
|
+
const escapeForGitHub = (text) => text.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
|
|
35
|
+
export function printFindings(findings, format) {
|
|
36
|
+
for (const finding of findings) {
|
|
37
|
+
const location = finding.line === undefined ? '' : `:${finding.line}`;
|
|
38
|
+
if (format === 'github') {
|
|
39
|
+
const lineParam = finding.line === undefined ? '' : `,line=${finding.line}`;
|
|
40
|
+
console.log(`::${finding.severity} file=${finding.path}${lineParam}::${escapeForGitHub(finding.message)}`);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
const mark = finding.severity === 'error' ? '✗' : '·';
|
|
44
|
+
console.log(` ${mark} ${finding.path}${location}: ${finding.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
export function exitCode(findings, strict) {
|
|
49
|
+
const hasErrors = findings.some((f) => f.severity === 'error');
|
|
50
|
+
const hasWarnings = findings.some((f) => f.severity === 'warning');
|
|
51
|
+
return hasErrors || (strict && hasWarnings) ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
export const readLines = (text) => text
|
|
54
|
+
.replace(/\r?\n$/, '')
|
|
55
|
+
.split(/\r?\n/)
|
|
56
|
+
.map((line, i) => ({ n: i + 1, text: line }));
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const HASH_STYLE = /\.(ya?ml|sh)$/;
|
|
2
|
+
const CSS_STYLE = /\.css$/;
|
|
3
|
+
const ASTRO_STYLE = /\.astro$/;
|
|
4
|
+
export function styleOf(file) {
|
|
5
|
+
if (HASH_STYLE.test(file))
|
|
6
|
+
return 'hash';
|
|
7
|
+
if (CSS_STYLE.test(file))
|
|
8
|
+
return 'css';
|
|
9
|
+
return ASTRO_STYLE.test(file) ? 'astro' : 'slash';
|
|
10
|
+
}
|
|
11
|
+
// Ritorna [chiusura attesa, lunghezza dell'apertura] se la riga apre un commento; chiusura
|
|
12
|
+
// vuota per i commenti a riga singola.
|
|
13
|
+
function opener(s, style) {
|
|
14
|
+
if (style === 'hash')
|
|
15
|
+
return s.startsWith('#') && !s.startsWith('#!') ? ['', 1] : null;
|
|
16
|
+
if (style !== 'css' && s.startsWith('//'))
|
|
17
|
+
return ['', 2];
|
|
18
|
+
if (s.startsWith('{/*'))
|
|
19
|
+
return ['*/}', 3];
|
|
20
|
+
if (s.startsWith('/*'))
|
|
21
|
+
return ['*/', 2];
|
|
22
|
+
if (style === 'astro' && s.startsWith('<!--'))
|
|
23
|
+
return ['-->', 4];
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
// Gli indici dei caratteri che stanno fuori da una stringa. Percorrere la riga è l'unico modo:
|
|
27
|
+
// contare le virgolette non regge un apostrofo ("L'esperienza") né un escape.
|
|
28
|
+
function* outsideStrings(text) {
|
|
29
|
+
let quote = '';
|
|
30
|
+
for (let i = 0; i < text.length; i++) {
|
|
31
|
+
const ch = text.charAt(i);
|
|
32
|
+
if (quote) {
|
|
33
|
+
if (ch === '\\')
|
|
34
|
+
i++;
|
|
35
|
+
else if (ch === quote)
|
|
36
|
+
quote = '';
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
40
|
+
quote = ch;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
// Fuori dalle stringhe un backslash sta solo nelle regex: "\\/\\/" non è un commento.
|
|
44
|
+
if (ch === '\\') {
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
yield i;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// In YAML e shell "#" apre un commento solo dopo uno spazio: "url#frag" è un valore.
|
|
52
|
+
const opensComment = (text, i, style) => style === 'hash'
|
|
53
|
+
? text.charAt(i) === '#' && /\s/.test(text.charAt(i - 1))
|
|
54
|
+
: text.charAt(i) === '/' && (text[i + 1] === '/' || text[i + 1] === '*');
|
|
55
|
+
// Il commento in coda a una riga di codice, se c'è.
|
|
56
|
+
function trailingComment(text, style) {
|
|
57
|
+
if (style === 'css')
|
|
58
|
+
return null;
|
|
59
|
+
for (const i of outsideStrings(text))
|
|
60
|
+
if (i > 0 && opensComment(text, i, style))
|
|
61
|
+
return text.slice(i);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
export function commentBlocks(lines, style) {
|
|
65
|
+
const blocks = [];
|
|
66
|
+
let current = [];
|
|
67
|
+
let closer = '';
|
|
68
|
+
const flush = () => {
|
|
69
|
+
if (current.length > 0)
|
|
70
|
+
blocks.push({ lines: current, trailing: false });
|
|
71
|
+
current = [];
|
|
72
|
+
};
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
const s = line.text.trim();
|
|
75
|
+
if (closer) {
|
|
76
|
+
current.push(line);
|
|
77
|
+
if (s.includes(closer))
|
|
78
|
+
closer = '';
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const opened = opener(s, style);
|
|
82
|
+
if (opened) {
|
|
83
|
+
const [expected, skip] = opened;
|
|
84
|
+
current.push(line);
|
|
85
|
+
if (expected && s.indexOf(expected, skip) === -1)
|
|
86
|
+
closer = expected;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
flush();
|
|
90
|
+
const trailing = trailingComment(line.text, style);
|
|
91
|
+
if (trailing)
|
|
92
|
+
blocks.push({ lines: [{ n: line.n, text: trailing }], trailing: true });
|
|
93
|
+
}
|
|
94
|
+
flush();
|
|
95
|
+
return blocks;
|
|
96
|
+
}
|
package/dist/lib/git.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
function git(args, { allowFailure = false } = {}) {
|
|
5
|
+
const result = spawnSync('git', args, { encoding: 'utf8' });
|
|
6
|
+
if (result.status !== 0) {
|
|
7
|
+
if (allowFailure)
|
|
8
|
+
return '';
|
|
9
|
+
throw new Error(result.stderr.trim() || `git ${args.join(' ')} fallito`);
|
|
10
|
+
}
|
|
11
|
+
return result.stdout;
|
|
12
|
+
}
|
|
13
|
+
// Senza -z git cita ed escapa i path non ASCII ("docs/\303\250.md"): existsSync non li trova
|
|
14
|
+
// e il file sparisce dalla passata senza errori.
|
|
15
|
+
const gitPaths = (subcommand, args) => git([subcommand, '-z', ...args])
|
|
16
|
+
.split('\0')
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
export const trackedAndUntracked = () => [
|
|
19
|
+
...gitPaths('ls-files', []),
|
|
20
|
+
...gitPaths('ls-files', ['--others', '--exclude-standard']),
|
|
21
|
+
];
|
|
22
|
+
function ensureCommit(ref) {
|
|
23
|
+
const exists = spawnSync('git', ['cat-file', '-e', `${ref}^{commit}`], {
|
|
24
|
+
stdio: 'ignore',
|
|
25
|
+
});
|
|
26
|
+
if (exists.status === 0)
|
|
27
|
+
return;
|
|
28
|
+
// Checkout shallow in CI: la base della PR spesso non c'è in locale.
|
|
29
|
+
git(['fetch', '--no-tags', 'origin', ref]);
|
|
30
|
+
}
|
|
31
|
+
function defaultBase() {
|
|
32
|
+
const originHead = git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], {
|
|
33
|
+
allowFailure: true,
|
|
34
|
+
}).trim();
|
|
35
|
+
if (originHead)
|
|
36
|
+
return originHead;
|
|
37
|
+
for (const candidate of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
38
|
+
if (git(['rev-parse', '--verify', '--quiet', candidate], {
|
|
39
|
+
allowFailure: true,
|
|
40
|
+
}).trim())
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
throw new Error('nessuna base trovata: passa --base <ref>');
|
|
44
|
+
}
|
|
45
|
+
// Sulle PR GitHub il diff va fatto tra gli SHA dell'evento, non tra branch locali.
|
|
46
|
+
function pullRequestShas() {
|
|
47
|
+
const { GITHUB_EVENT_NAME, GITHUB_EVENT_PATH } = process.env;
|
|
48
|
+
if (GITHUB_EVENT_NAME !== 'pull_request' || !GITHUB_EVENT_PATH || !existsSync(GITHUB_EVENT_PATH))
|
|
49
|
+
return undefined;
|
|
50
|
+
const { pull_request: pr } = JSON.parse(readFileSync(GITHUB_EVENT_PATH, 'utf8'));
|
|
51
|
+
return pr?.base?.sha && pr?.head?.sha ? { base: pr.base.sha, head: pr.head.sha } : undefined;
|
|
52
|
+
}
|
|
53
|
+
export function changedFiles({ base, head }) {
|
|
54
|
+
const pr = base ? undefined : pullRequestShas();
|
|
55
|
+
const from = base ?? pr?.base ?? defaultBase();
|
|
56
|
+
const to = head ?? pr?.head ?? 'HEAD';
|
|
57
|
+
ensureCommit(from);
|
|
58
|
+
ensureCommit(to);
|
|
59
|
+
let committed;
|
|
60
|
+
try {
|
|
61
|
+
committed = gitPaths('diff', ['--name-only', '--diff-filter=ACMR', `${from}...${to}`]);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new Error(`diff ${from}...${to} fallito (clone shallow? prova "git fetch --unshallow"): ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
// In locale contano anche le modifiche non ancora committate; con un head esplicito o in PR no.
|
|
67
|
+
const includeWorkingTree = !pr && !head;
|
|
68
|
+
const workingTree = includeWorkingTree
|
|
69
|
+
? [
|
|
70
|
+
...gitPaths('diff', ['--name-only', '--diff-filter=ACMR']),
|
|
71
|
+
...gitPaths('diff', ['--cached', '--name-only', '--diff-filter=ACMR']),
|
|
72
|
+
...gitPaths('ls-files', ['--others', '--exclude-standard']),
|
|
73
|
+
]
|
|
74
|
+
: [];
|
|
75
|
+
return [...new Set([...committed, ...workingTree])];
|
|
76
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// \b è ASCII: "già", "perché" e "è" con \b non matchano mai. Il trattino nei confini esclude
|
|
2
|
+
// "non-null" e "un-styled" dal conteggio italiano.
|
|
3
|
+
const words = (list) => new RegExp(`(?<![\\p{L}\\p{N}-])(?:${list.join('|')})(?![\\p{L}\\p{N}-])`, 'giu');
|
|
4
|
+
// Solo parole funzione di una lingua sola: fuori "per" (per request), "in", "a", "come",
|
|
5
|
+
// "solo", "so", "no", "all" (all'inizio).
|
|
6
|
+
export const ITALIAN = words([
|
|
7
|
+
'è',
|
|
8
|
+
'che',
|
|
9
|
+
'non',
|
|
10
|
+
'il',
|
|
11
|
+
'la',
|
|
12
|
+
'lo',
|
|
13
|
+
'le',
|
|
14
|
+
'gli',
|
|
15
|
+
'un',
|
|
16
|
+
'una',
|
|
17
|
+
'uno',
|
|
18
|
+
'di',
|
|
19
|
+
'da',
|
|
20
|
+
'su',
|
|
21
|
+
'se',
|
|
22
|
+
'ma',
|
|
23
|
+
'più',
|
|
24
|
+
'già',
|
|
25
|
+
'poi',
|
|
26
|
+
'qui',
|
|
27
|
+
'mai',
|
|
28
|
+
'cui',
|
|
29
|
+
'dove',
|
|
30
|
+
'del',
|
|
31
|
+
'dello',
|
|
32
|
+
'della',
|
|
33
|
+
'delle',
|
|
34
|
+
'degli',
|
|
35
|
+
'dei',
|
|
36
|
+
'dal',
|
|
37
|
+
'dalla',
|
|
38
|
+
'dalle',
|
|
39
|
+
'dagli',
|
|
40
|
+
'dai',
|
|
41
|
+
'nel',
|
|
42
|
+
'nello',
|
|
43
|
+
'nella',
|
|
44
|
+
'nelle',
|
|
45
|
+
'negli',
|
|
46
|
+
'nei',
|
|
47
|
+
'sul',
|
|
48
|
+
'sulla',
|
|
49
|
+
'sulle',
|
|
50
|
+
'sui',
|
|
51
|
+
'al',
|
|
52
|
+
'allo',
|
|
53
|
+
'alla',
|
|
54
|
+
'alle',
|
|
55
|
+
'agli',
|
|
56
|
+
'ai',
|
|
57
|
+
'con',
|
|
58
|
+
'senza',
|
|
59
|
+
'ogni',
|
|
60
|
+
'anche',
|
|
61
|
+
'quindi',
|
|
62
|
+
'perché',
|
|
63
|
+
'sono',
|
|
64
|
+
'viene',
|
|
65
|
+
'vengono',
|
|
66
|
+
'essere',
|
|
67
|
+
'può',
|
|
68
|
+
'deve',
|
|
69
|
+
'devono',
|
|
70
|
+
'possono',
|
|
71
|
+
'fatto',
|
|
72
|
+
'questo',
|
|
73
|
+
'questa',
|
|
74
|
+
'questi',
|
|
75
|
+
'queste',
|
|
76
|
+
'quello',
|
|
77
|
+
'quella',
|
|
78
|
+
'quando',
|
|
79
|
+
'mentre',
|
|
80
|
+
'invece',
|
|
81
|
+
'oppure',
|
|
82
|
+
'però',
|
|
83
|
+
'sempre',
|
|
84
|
+
'tutti',
|
|
85
|
+
'tutte',
|
|
86
|
+
'tutto',
|
|
87
|
+
'stesso',
|
|
88
|
+
'stessa',
|
|
89
|
+
'cosa',
|
|
90
|
+
'così',
|
|
91
|
+
]);
|
|
92
|
+
export const ENGLISH = words([
|
|
93
|
+
'the',
|
|
94
|
+
'and',
|
|
95
|
+
'that',
|
|
96
|
+
'with',
|
|
97
|
+
'from',
|
|
98
|
+
'this',
|
|
99
|
+
'these',
|
|
100
|
+
'those',
|
|
101
|
+
'is',
|
|
102
|
+
'are',
|
|
103
|
+
'was',
|
|
104
|
+
'were',
|
|
105
|
+
'be',
|
|
106
|
+
'been',
|
|
107
|
+
'have',
|
|
108
|
+
'has',
|
|
109
|
+
'had',
|
|
110
|
+
'of',
|
|
111
|
+
'to',
|
|
112
|
+
'for',
|
|
113
|
+
'on',
|
|
114
|
+
'at',
|
|
115
|
+
'by',
|
|
116
|
+
'or',
|
|
117
|
+
'as',
|
|
118
|
+
'if',
|
|
119
|
+
'not',
|
|
120
|
+
'it',
|
|
121
|
+
'its',
|
|
122
|
+
'we',
|
|
123
|
+
'you',
|
|
124
|
+
'they',
|
|
125
|
+
'their',
|
|
126
|
+
'our',
|
|
127
|
+
'your',
|
|
128
|
+
'an',
|
|
129
|
+
'when',
|
|
130
|
+
'only',
|
|
131
|
+
'also',
|
|
132
|
+
'already',
|
|
133
|
+
'every',
|
|
134
|
+
'each',
|
|
135
|
+
'any',
|
|
136
|
+
'some',
|
|
137
|
+
'more',
|
|
138
|
+
'most',
|
|
139
|
+
'but',
|
|
140
|
+
'which',
|
|
141
|
+
'what',
|
|
142
|
+
'does',
|
|
143
|
+
'without',
|
|
144
|
+
'because',
|
|
145
|
+
'will',
|
|
146
|
+
'should',
|
|
147
|
+
'can',
|
|
148
|
+
'must',
|
|
149
|
+
'use',
|
|
150
|
+
'used',
|
|
151
|
+
'using',
|
|
152
|
+
'into',
|
|
153
|
+
'than',
|
|
154
|
+
'then',
|
|
155
|
+
'there',
|
|
156
|
+
'here',
|
|
157
|
+
]);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// \b è ASCII anche con il flag u: "\bpiù\b" e "\bgià\b" non matchano mai. I confini di parola
|
|
2
|
+
// vanno scritti a mano con \p{L}.
|
|
3
|
+
const phrase = (alternatives) => new RegExp(`(?<![\\p{L}\\p{N}])(?:${alternatives.join('|')})(?![\\p{L}\\p{N}])`, 'iu');
|
|
4
|
+
export const PAST_TENSE = [
|
|
5
|
+
phrase([
|
|
6
|
+
"prima (?:era|c'era|veniva|faceva|andava|lo|si)",
|
|
7
|
+
'in precedenza',
|
|
8
|
+
'una volta era',
|
|
9
|
+
'era stato',
|
|
10
|
+
'in passato',
|
|
11
|
+
'storicamente',
|
|
12
|
+
]),
|
|
13
|
+
// "non più di 3" è una quantità, non un cambiamento.
|
|
14
|
+
phrase(['non (?:è |viene |serve )?più(?! di)']),
|
|
15
|
+
phrase(["ora (?:è|invece|lo|la|il|si|c'è|non)", 'adesso', 'per ora', 'temporaneamente']),
|
|
16
|
+
phrase(['sostituisce', 'al posto di', 'vecchi[oa]']),
|
|
17
|
+
phrase(['used to', 'previously', 'no longer', 'replaces the', 'replaced by', 'removed in', 'for now', 'temporarily']),
|
|
18
|
+
/#\d{1,3}(?!\d)/,
|
|
19
|
+
];
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elia97/officina",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Gli strumenti dei progetti nati dai template di Elia: gate di qualità e, più avanti, generatori.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Elia97/officina.git"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"officina": "dist/bin.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=24.0.0"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@biomejs/biome": "2.5.10",
|
|
26
|
+
"@types/node": "^24.13.5",
|
|
27
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
28
|
+
"fallow": "^3.26.0",
|
|
29
|
+
"typescript": "^7.0.2",
|
|
30
|
+
"vitest": "^4.1.11"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.build.json",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run --coverage",
|
|
36
|
+
"format": "biome check --write .",
|
|
37
|
+
"check:deadcode": "fallow dead-code",
|
|
38
|
+
"check:health": "fallow health --complexity --coverage coverage/coverage-final.json",
|
|
39
|
+
"ci": "biome ci --error-on-warnings . && pnpm run typecheck && pnpm run check:deadcode && pnpm run test && pnpm run check:health"
|
|
40
|
+
}
|
|
41
|
+
}
|