universal-dev-standards 6.14.0-beta.4 → 6.14.0-beta.5
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/bin/uds.js +7 -1
- package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
- package/bundled/core/full-coverage-testing.md +57 -3
- package/bundled/locales/zh-CN/CHANGELOG.md +29 -2
- package/bundled/locales/zh-CN/README.md +1 -1
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
- package/bundled/locales/zh-TW/CHANGELOG.md +29 -2
- package/bundled/locales/zh-TW/README.md +1 -1
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
- package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
- package/bundled/templates/gates/check-stubs.mjs +644 -0
- package/package.json +2 -2
- package/src/commands/audit.js +11 -0
- package/src/commands/check.js +124 -24
- package/src/commands/init.js +16 -0
- package/src/commands/update.js +180 -19
- package/src/core/install-records.js +2 -1
- package/src/i18n/messages.js +50 -9
- package/src/reconciler/backup-manager.js +418 -82
- package/src/reconciler/index.js +27 -5
- package/src/reconciler/install-roots.js +90 -0
- package/src/reconciler/plan-executor.js +23 -2
- package/src/uninstallers/hook-uninstaller.js +2 -1
- package/src/utils/command-hash-ownership.js +103 -0
- package/src/utils/copier.js +21 -1
- package/src/utils/gate-scripts.js +141 -0
- package/src/utils/health-scorer.js +10 -7
- package/src/utils/skill-hash-ownership.js +64 -0
- package/src/utils/skills-installer.js +12 -1
- package/src/utils/test-change-check.js +160 -0
- package/src/utils/test-policy.js +214 -0
- package/src/utils/update-summary.js +29 -0
- package/standards-registry.json +7 -7
- package/bundled/extensions/languages/php/fat-free-patterns.md +0 -915
- package/bundled/extensions/languages/php/php-style.md +0 -693
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
//
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// check-stubs —— 空殼與暫時實作掃描(UDS full-coverage-testing 的出貨閘門)
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// What it finds, in the source files of any project:
|
|
9
|
+
// stub-marker a `// WARNING: STUB — Remove before UAT` marker (a declared placeholder:
|
|
10
|
+
// UDS's full-coverage-testing standard says it must be gone before UAT)
|
|
11
|
+
// not-implemented a body that says it is not implemented (raise NotImplementedError,
|
|
12
|
+
// todo!(), TODO(), throw new Error("not implemented") ...) with no
|
|
13
|
+
// STUB / COVERAGE_EXEMPT marker beside it — a SILENT placeholder
|
|
14
|
+
// empty-function a named function whose body is empty (or only `pass` / `...`) with no
|
|
15
|
+
// such marker — a silent empty shell
|
|
16
|
+
//
|
|
17
|
+
// Written into your project by `uds init` (XSPEC-444 R5). It is YOURS: edit it, or replace
|
|
18
|
+
// it. `uds update` never overwrites it. `uds check` (which your pre-commit hook runs) calls
|
|
19
|
+
// it and prints what it finds as a WARNING; it does not stop your commit unless you set
|
|
20
|
+
// "mode": "block" in .standards/test-policy.json. To make STUB markers stop a push to main or
|
|
21
|
+
// a deploy (the standard's deployment gates), run it yourself in that step:
|
|
22
|
+
// node scripts/check-stubs.mjs # exit 1 when anything is found
|
|
23
|
+
//
|
|
24
|
+
// Usage:
|
|
25
|
+
// node scripts/check-stubs.mjs scan the whole project
|
|
26
|
+
// node scripts/check-stubs.mjs --staged scan only the files staged for commit
|
|
27
|
+
// node scripts/check-stubs.mjs --json machine-readable result
|
|
28
|
+
//
|
|
29
|
+
// Exit codes: 0 nothing found | 1 findings | 2 could not judge (the scanner's own
|
|
30
|
+
// self-test failed, or git was unreadable in --staged mode). 2 is never a pass.
|
|
31
|
+
//
|
|
32
|
+
// Marker rule: a placeholder is DECLARED when the text `STUB` or `COVERAGE_EXEMPT` is on
|
|
33
|
+
// the same line or in the three lines above it. A declared placeholder is reported once, as
|
|
34
|
+
// its marker (stub-marker), not again as an empty function.
|
|
35
|
+
//
|
|
36
|
+
// Empty-function detection has rules for JavaScript/TypeScript, Python, Go, Rust, Ruby and
|
|
37
|
+
// PHP. In other languages only markers and "not implemented" bodies are found, and the
|
|
38
|
+
// summary says so — it does not pretend to have looked.
|
|
39
|
+
//
|
|
40
|
+
// Heuristic by nature: it reads text, it does not understand your program. An empty function
|
|
41
|
+
// that is intentional (an interface default, a hook point) belongs next to a
|
|
42
|
+
// `// COVERAGE_EXEMPT: <reason>` comment, which states why.
|
|
43
|
+
|
|
44
|
+
import { existsSync, readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs';
|
|
45
|
+
import { join } from 'node:path';
|
|
46
|
+
import { execFileSync } from 'node:child_process';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
|
|
49
|
+
// ---- BEGIN SHARED POLICY (identical in templates/gates/check-anti-fake-tests.mjs and check-stubs.mjs; test-policy-drift.test.js compares them) ----
|
|
50
|
+
/** Where an adopter's policy lives (inside `.standards/`, beside release-config.yaml). */
|
|
51
|
+
export const POLICY_FILE = '.standards/test-policy.json';
|
|
52
|
+
|
|
53
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
54
|
+
mode: 'warn',
|
|
55
|
+
testDirs: Object.freeze(['test', 'tests', '__tests__', 'spec', 'e2e']),
|
|
56
|
+
testPatterns: Object.freeze([
|
|
57
|
+
'*.test.*', '*.spec.*', '*_test.*', '*_spec.*', 'test_*.*', 'conftest.py',
|
|
58
|
+
'*Test.*', '*Tests.*', '*Spec.scala', '*Spec.kt', '*Spec.groovy'
|
|
59
|
+
]),
|
|
60
|
+
sourceExtensions: Object.freeze([
|
|
61
|
+
'js', 'mjs', 'cjs', 'jsx', 'ts', 'tsx', 'mts', 'cts', 'vue', 'svelte', 'astro',
|
|
62
|
+
'py', 'pyi', 'java', 'kt', 'kts', 'scala', 'groovy', 'go', 'rs', 'rb', 'php', 'cs', 'fs', 'vb',
|
|
63
|
+
'swift', 'm', 'mm', 'dart', 'c', 'h', 'cc', 'cpp', 'cxx', 'hpp', 'hh',
|
|
64
|
+
'ex', 'exs', 'erl', 'hrl', 'lua', 'pl', 'pm', 'r', 'jl', 'clj', 'cljs', 'hs', 'ml', 'mli',
|
|
65
|
+
'sh', 'bash', 'zsh', 'ps1'
|
|
66
|
+
]),
|
|
67
|
+
nonCodeExtensions: Object.freeze([
|
|
68
|
+
'md', 'mdx', 'txt', 'rst', 'adoc', 'json', 'jsonc', 'json5', 'yaml', 'yml', 'toml', 'ini', 'cfg',
|
|
69
|
+
'conf', 'properties', 'env', 'lock', 'xml', 'csv', 'tsv', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'ico',
|
|
70
|
+
'webp', 'avif', 'pdf', 'woff', 'woff2', 'ttf', 'otf', 'eot', 'map', 'html', 'htm', 'css', 'scss',
|
|
71
|
+
'sass', 'less', 'sql', 'gitignore', 'gitattributes', 'editorconfig', 'npmrc', 'nvmrc', 'license',
|
|
72
|
+
'log', 'snap'
|
|
73
|
+
]),
|
|
74
|
+
nonCodeNames: Object.freeze([
|
|
75
|
+
'LICENSE', 'README', 'CHANGELOG', 'NOTICE', 'AUTHORS', 'CODEOWNERS', 'Dockerfile', 'Makefile', 'Procfile'
|
|
76
|
+
]),
|
|
77
|
+
ignoreDirs: Object.freeze([
|
|
78
|
+
'node_modules', 'vendor', 'dist', 'build', 'coverage', '.git', '.standards', '.claude', '.husky',
|
|
79
|
+
'.github', 'docs', 'target', '__pycache__', '.venv', 'venv', '.next', '.nuxt', '.idea', '.vscode',
|
|
80
|
+
'.ruff_cache', '.pytest_cache', '.mypy_cache', '.tox', '.gradle', '.cache', '.turbo', '.svelte-kit',
|
|
81
|
+
'.parcel-cache', '.terraform', '.dart_tool', '.eggs'
|
|
82
|
+
]),
|
|
83
|
+
ignore: Object.freeze(['**/*.min.js', '**/*.min.css', '**/*.d.ts']),
|
|
84
|
+
exempt: Object.freeze([])
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const norm = (p) => String(p).replace(/\\/g, '/').replace(/^\.\//, '');
|
|
88
|
+
|
|
89
|
+
/** Glob → RegExp. `*` stays inside a path segment, `**` crosses them, `?` is one non-slash character. */
|
|
90
|
+
export function globToRegex(glob) {
|
|
91
|
+
const g = norm(glob);
|
|
92
|
+
let re = '';
|
|
93
|
+
for (let i = 0; i < g.length; i++) {
|
|
94
|
+
const c = g[i];
|
|
95
|
+
if (c === '*') {
|
|
96
|
+
if (g[i + 1] === '*') {
|
|
97
|
+
if (g[i + 2] === '/') { re += '(?:.*/)?'; i += 2; } else { re += '.*'; i += 1; }
|
|
98
|
+
} else {
|
|
99
|
+
re += '[^/]*';
|
|
100
|
+
}
|
|
101
|
+
} else if (c === '?') {
|
|
102
|
+
re += '[^/]';
|
|
103
|
+
} else if ('\\^$+.()|{}[]'.includes(c)) {
|
|
104
|
+
re += '\\' + c;
|
|
105
|
+
} else {
|
|
106
|
+
re += c;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new RegExp(`^${re}$`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A glob with no slash is a file-name glob (matches the basename anywhere); otherwise it matches the whole path. */
|
|
113
|
+
function matchesGlob(relPath, glob) {
|
|
114
|
+
const p = norm(relPath);
|
|
115
|
+
const g = norm(glob);
|
|
116
|
+
const target = g.includes('/') ? p : p.split('/').pop();
|
|
117
|
+
return globToRegex(g).test(target);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const extOf = (relPath) => {
|
|
121
|
+
const base = norm(relPath).split('/').pop();
|
|
122
|
+
const i = base.lastIndexOf('.');
|
|
123
|
+
return i > 0 ? base.slice(i + 1).toLowerCase() : '';
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Read `.standards/test-policy.json` and merge it over the defaults.
|
|
128
|
+
* @returns {{ policy: object, problems: string[], source: string|null }}
|
|
129
|
+
* `problems` is never swallowed: an unreadable file or a bad value is reported, and the
|
|
130
|
+
* defaults (mode "warn") are what runs.
|
|
131
|
+
*/
|
|
132
|
+
export function loadPolicy(projectPath) {
|
|
133
|
+
const problems = [];
|
|
134
|
+
const policy = {
|
|
135
|
+
mode: DEFAULT_POLICY.mode,
|
|
136
|
+
testDirs: [...DEFAULT_POLICY.testDirs],
|
|
137
|
+
testPatterns: [...DEFAULT_POLICY.testPatterns],
|
|
138
|
+
sourceExtensions: [...DEFAULT_POLICY.sourceExtensions],
|
|
139
|
+
nonCodeExtensions: [...DEFAULT_POLICY.nonCodeExtensions],
|
|
140
|
+
nonCodeNames: [...DEFAULT_POLICY.nonCodeNames],
|
|
141
|
+
ignoreDirs: [...DEFAULT_POLICY.ignoreDirs],
|
|
142
|
+
ignore: [...DEFAULT_POLICY.ignore],
|
|
143
|
+
exempt: []
|
|
144
|
+
};
|
|
145
|
+
const file = join(projectPath, POLICY_FILE);
|
|
146
|
+
if (!existsSync(file)) return { policy, problems, source: null };
|
|
147
|
+
|
|
148
|
+
let raw;
|
|
149
|
+
try {
|
|
150
|
+
raw = JSON.parse(readFileSync(file, 'utf-8').replace(/^\uFEFF/, ''));
|
|
151
|
+
} catch (e) {
|
|
152
|
+
problems.push(`${POLICY_FILE} cannot be read (${e.message}); defaults are in effect`);
|
|
153
|
+
return { policy, problems, source: POLICY_FILE };
|
|
154
|
+
}
|
|
155
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
156
|
+
problems.push(`${POLICY_FILE} must be a JSON object; defaults are in effect`);
|
|
157
|
+
return { policy, problems, source: POLICY_FILE };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (raw.mode !== undefined) {
|
|
161
|
+
if (raw.mode === 'warn' || raw.mode === 'block') policy.mode = raw.mode;
|
|
162
|
+
else problems.push(`${POLICY_FILE}: "mode" must be "warn" or "block", got ${JSON.stringify(raw.mode)}; "warn" is in effect`);
|
|
163
|
+
}
|
|
164
|
+
for (const key of ['testDirs', 'testPatterns', 'sourceExtensions', 'nonCodeExtensions', 'ignore']) {
|
|
165
|
+
if (raw[key] === undefined) continue;
|
|
166
|
+
if (!Array.isArray(raw[key]) || raw[key].some((v) => typeof v !== 'string' || !v)) {
|
|
167
|
+
problems.push(`${POLICY_FILE}: "${key}" must be an array of non-empty strings; it is ignored`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const add = key === 'sourceExtensions' || key === 'nonCodeExtensions'
|
|
171
|
+
? raw[key].map((v) => v.replace(/^\./, '').toLowerCase())
|
|
172
|
+
: raw[key];
|
|
173
|
+
policy[key] = [...policy[key], ...add];
|
|
174
|
+
}
|
|
175
|
+
if (raw.exempt !== undefined) {
|
|
176
|
+
if (!Array.isArray(raw.exempt)) {
|
|
177
|
+
problems.push(`${POLICY_FILE}: "exempt" must be an array; it is ignored`);
|
|
178
|
+
} else {
|
|
179
|
+
raw.exempt.forEach((e, i) => {
|
|
180
|
+
const okPattern = e && typeof e.pattern === 'string' && e.pattern.trim();
|
|
181
|
+
const okReason = e && typeof e.reason === 'string' && e.reason.trim();
|
|
182
|
+
if (!okPattern) problems.push(`${POLICY_FILE}: exempt[${i}] has no "pattern"; it is ignored`);
|
|
183
|
+
else if (!okReason) problems.push(`${POLICY_FILE}: exempt[${i}] ("${e.pattern}") has no "reason" and is NOT honored — an exemption must say why`);
|
|
184
|
+
else policy.exempt.push({ pattern: e.pattern, reason: e.reason.trim() });
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return { policy, problems, source: POLICY_FILE };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Classify one project-relative path.
|
|
193
|
+
* @returns {{ kind: 'ignored'|'noncode'|'test'|'source'|'unclassified', ext: string, exempt?: {pattern:string, reason:string} }}
|
|
194
|
+
*/
|
|
195
|
+
export function classifyPath(relPath, policy = DEFAULT_POLICY) {
|
|
196
|
+
const p = norm(relPath);
|
|
197
|
+
const segments = p.split('/');
|
|
198
|
+
const base = segments[segments.length - 1];
|
|
199
|
+
const dirs = segments.slice(0, -1);
|
|
200
|
+
const ext = extOf(p);
|
|
201
|
+
const sets = {
|
|
202
|
+
source: new Set(policy.sourceExtensions || DEFAULT_POLICY.sourceExtensions),
|
|
203
|
+
noncode: new Set(policy.nonCodeExtensions || DEFAULT_POLICY.nonCodeExtensions),
|
|
204
|
+
names: new Set(policy.nonCodeNames || DEFAULT_POLICY.nonCodeNames),
|
|
205
|
+
ignoreDirs: new Set(policy.ignoreDirs || DEFAULT_POLICY.ignoreDirs)
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
if (dirs.some((d) => sets.ignoreDirs.has(d))) return { kind: 'ignored', ext };
|
|
209
|
+
if ((policy.ignore || DEFAULT_POLICY.ignore).some((g) => matchesGlob(p, g))) return { kind: 'ignored', ext };
|
|
210
|
+
|
|
211
|
+
const docsLike = sets.noncode.has(ext) || sets.names.has(base) || sets.names.has(base.replace(/\.[^.]*$/, ''));
|
|
212
|
+
const inTestDir = dirs.some((d) => (policy.testDirs || DEFAULT_POLICY.testDirs).includes(d));
|
|
213
|
+
const testNamed = (policy.testPatterns || DEFAULT_POLICY.testPatterns).some((g) => matchesGlob(p, g));
|
|
214
|
+
|
|
215
|
+
// A test is a test even when its data is JSON; but a README inside tests/ is documentation.
|
|
216
|
+
if ((inTestDir || testNamed) && !['md', 'mdx', 'txt', 'rst', 'adoc'].includes(ext)) return { kind: 'test', ext };
|
|
217
|
+
// Dot-files (.prettierrc, .eslintrc.js, .env.local ...) are tool configuration, not product code.
|
|
218
|
+
if (docsLike || base.startsWith('.')) return { kind: 'noncode', ext };
|
|
219
|
+
if (sets.source.has(ext)) {
|
|
220
|
+
const exempt = (policy.exempt || []).find((e) => matchesGlob(p, e.pattern));
|
|
221
|
+
return exempt ? { kind: 'source', ext, exempt } : { kind: 'source', ext };
|
|
222
|
+
}
|
|
223
|
+
return { kind: 'unclassified', ext };
|
|
224
|
+
}
|
|
225
|
+
// ---- END SHARED POLICY ----
|
|
226
|
+
|
|
227
|
+
// ---- BEGIN SHARED ENGINE (identical in check-anti-fake-tests.mjs and check-stubs.mjs; test-policy-drift.test.js compares them) ----
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Language families this script has rules for, keyed by file extension.
|
|
231
|
+
* An extension that is not here is UNSUPPORTED: the script says so by name. It never
|
|
232
|
+
* counts a file it cannot read as a file that passed.
|
|
233
|
+
*/
|
|
234
|
+
export const FAMILY_BY_EXT = Object.freeze({
|
|
235
|
+
js: 'js', mjs: 'js', cjs: 'js', jsx: 'js', ts: 'js', tsx: 'js', mts: 'js', cts: 'js', vue: 'js', svelte: 'js',
|
|
236
|
+
py: 'python', pyi: 'python',
|
|
237
|
+
java: 'jvm', kt: 'jvm', kts: 'jvm', scala: 'jvm', groovy: 'jvm', cs: 'jvm',
|
|
238
|
+
go: 'go', rs: 'rust', rb: 'ruby', ex: 'elixir', exs: 'elixir',
|
|
239
|
+
php: 'php', swift: 'swift', dart: 'dart', lua: 'lua',
|
|
240
|
+
c: 'cpp', h: 'cpp', cc: 'cpp', cpp: 'cpp', cxx: 'cpp', hpp: 'cpp', hh: 'cpp'
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const LEX = {
|
|
244
|
+
js: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"', '`'], triple: [], regex: true },
|
|
245
|
+
python: { line: ['#'], block: [], quotes: ['\'', '"'], triple: ['"""', '\'\'\''] },
|
|
246
|
+
jvm: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: ['"""'] },
|
|
247
|
+
go: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"', '`'], triple: [] },
|
|
248
|
+
rust: { line: ['//'], block: [['/*', '*/']], quotes: ['"', '\''], triple: [], rustChar: true },
|
|
249
|
+
ruby: { line: ['#'], block: [], quotes: ['\'', '"'], triple: [] },
|
|
250
|
+
elixir: { line: ['#'], block: [], quotes: ['"', '\''], triple: ['"""'] },
|
|
251
|
+
php: { line: ['//', '#'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: [], phpAttr: true },
|
|
252
|
+
swift: { line: ['//'], block: [['/*', '*/']], quotes: ['"'], triple: ['"""'] },
|
|
253
|
+
dart: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: ['"""', '\'\'\''] },
|
|
254
|
+
lua: { line: ['--'], block: [['--[[', ']]']], quotes: ['\'', '"'], triple: [] },
|
|
255
|
+
cpp: { line: ['//'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: [] },
|
|
256
|
+
other: { line: ['//', '#'], block: [['/*', '*/']], quotes: ['\'', '"'], triple: [] }
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Same-length copies of `text`: `noComments` (comments blanked) and `noStrings` (comments
|
|
261
|
+
* AND the inside of string literals blanked). Same length means an index found in one is
|
|
262
|
+
* the same place in the other and in the original — which is how names are read back.
|
|
263
|
+
*/
|
|
264
|
+
export function maskSource(text, family) {
|
|
265
|
+
const lex = LEX[family] || LEX.other;
|
|
266
|
+
const nc = text.split('');
|
|
267
|
+
const ns = text.split('');
|
|
268
|
+
const n = text.length;
|
|
269
|
+
const blank = (arr, from, to) => { for (let k = from; k < to; k++) if (arr[k] !== '\n') arr[k] = ' '; };
|
|
270
|
+
let i = 0;
|
|
271
|
+
while (i < n) {
|
|
272
|
+
let hit = false;
|
|
273
|
+
for (const [open, close] of lex.block) {
|
|
274
|
+
if (text.startsWith(open, i)) {
|
|
275
|
+
const end = text.indexOf(close, i + open.length);
|
|
276
|
+
const stop = end === -1 ? n : end + close.length;
|
|
277
|
+
blank(nc, i, stop); blank(ns, i, stop);
|
|
278
|
+
i = stop; hit = true; break;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (hit) continue;
|
|
282
|
+
for (const lc of lex.line) {
|
|
283
|
+
if (text.startsWith(lc, i) && !(lex.phpAttr && lc === '#' && text[i + 1] === '[')) {
|
|
284
|
+
let end = text.indexOf('\n', i);
|
|
285
|
+
if (end === -1) end = n;
|
|
286
|
+
blank(nc, i, end); blank(ns, i, end);
|
|
287
|
+
i = end; hit = true; break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (hit) continue;
|
|
291
|
+
const triple = lex.triple.find((t) => text.startsWith(t, i));
|
|
292
|
+
if (triple) {
|
|
293
|
+
const end = text.indexOf(triple, i + 3);
|
|
294
|
+
// The delimiters go too: a closing `"""` left at column 0 would end an indented block early.
|
|
295
|
+
const stop = end === -1 ? n : end + 3;
|
|
296
|
+
blank(ns, i, stop);
|
|
297
|
+
i = stop;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
const ch = text[i];
|
|
301
|
+
if (lex.regex && ch === '/') {
|
|
302
|
+
// A regex literal may hold a quote or a backtick; read it as one token so it cannot open a string.
|
|
303
|
+
let p = i - 1;
|
|
304
|
+
while (p >= 0 && /[ \t]/.test(text[p])) p--;
|
|
305
|
+
const before = p < 0 ? '' : text[p];
|
|
306
|
+
const word = /(?:^|[^\w$])(return|typeof|case|in|of|void|delete|throw|yield|await)$/.test(text.slice(Math.max(0, p - 9), p + 1));
|
|
307
|
+
if (p < 0 || '(,=:[!&|?{};+-*%<>~^\n'.includes(before) || word) {
|
|
308
|
+
let j = i + 1;
|
|
309
|
+
let inClass = false;
|
|
310
|
+
while (j < n) {
|
|
311
|
+
const c = text[j];
|
|
312
|
+
if (c === '\\') { j += 2; continue; }
|
|
313
|
+
if (c === '\n') { j = -1; break; }
|
|
314
|
+
if (c === '[') inClass = true;
|
|
315
|
+
else if (c === ']') inClass = false;
|
|
316
|
+
else if (c === '/' && !inClass) break;
|
|
317
|
+
j++;
|
|
318
|
+
}
|
|
319
|
+
if (j > i && j < n) { blank(ns, i + 1, j); i = j + 1; continue; }
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (lex.quotes.includes(ch)) {
|
|
323
|
+
if (lex.rustChar && ch === '\'') {
|
|
324
|
+
const isChar = text[i + 2] === '\'' || (text[i + 1] === '\\' && text[i + 3] === '\'');
|
|
325
|
+
if (!isChar) { i++; continue; }
|
|
326
|
+
}
|
|
327
|
+
const multiline = ch === '`';
|
|
328
|
+
let j = i + 1;
|
|
329
|
+
while (j < n) {
|
|
330
|
+
if (text[j] === '\\') { j += 2; continue; }
|
|
331
|
+
if (text[j] === ch) break;
|
|
332
|
+
if (text[j] === '\n' && !multiline) break;
|
|
333
|
+
j++;
|
|
334
|
+
}
|
|
335
|
+
blank(ns, i + 1, Math.min(j, n));
|
|
336
|
+
i = j + 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
i++;
|
|
340
|
+
}
|
|
341
|
+
return { noComments: nc.join(''), noStrings: ns.join('') };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Index of the bracket that closes the one at `open` (counting only that bracket pair), or -1. */
|
|
345
|
+
export function matchClose(s, open) {
|
|
346
|
+
const pairs = { '(': ')', '{': '}', '[': ']' };
|
|
347
|
+
const o = s[open];
|
|
348
|
+
const c = pairs[o];
|
|
349
|
+
if (!c) return -1;
|
|
350
|
+
let depth = 0;
|
|
351
|
+
for (let i = open; i < s.length; i++) {
|
|
352
|
+
if (s[i] === o) depth++;
|
|
353
|
+
else if (s[i] === c && --depth === 0) return i;
|
|
354
|
+
}
|
|
355
|
+
return -1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** A function that maps a character index to its 1-based line number. */
|
|
359
|
+
export function lineIndex(text) {
|
|
360
|
+
const starts = [0];
|
|
361
|
+
for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
|
|
362
|
+
return (idx) => {
|
|
363
|
+
let lo = 0; let hi = starts.length - 1;
|
|
364
|
+
while (lo < hi) {
|
|
365
|
+
const mid = (lo + hi + 1) >> 1;
|
|
366
|
+
if (starts[mid] <= idx) lo = mid; else hi = mid - 1;
|
|
367
|
+
}
|
|
368
|
+
return lo + 1;
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Every project-relative file under `root`, skipping the policy's ignored directories and symlinks. */
|
|
373
|
+
export function walkFiles(root, policy) {
|
|
374
|
+
const skip = new Set(policy.ignoreDirs);
|
|
375
|
+
const out = [];
|
|
376
|
+
const stack = [''];
|
|
377
|
+
while (stack.length > 0 && out.length < 200000) {
|
|
378
|
+
const rel = stack.pop();
|
|
379
|
+
let entries;
|
|
380
|
+
try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { continue; }
|
|
381
|
+
for (const e of entries) {
|
|
382
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
383
|
+
if (e.isSymbolicLink()) continue;
|
|
384
|
+
if (e.isDirectory()) { if (!skip.has(e.name)) stack.push(r); } else if (e.isFile()) out.push(r);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return out.sort();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Files staged for the next commit (added, copied, modified, renamed), or `{ error }`. */
|
|
391
|
+
export function stagedFiles(root) {
|
|
392
|
+
try {
|
|
393
|
+
const out = execFileSync('git', ['-c', 'core.quotepath=off', 'diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z'], {
|
|
394
|
+
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 64 * 1024 * 1024
|
|
395
|
+
});
|
|
396
|
+
return { files: out.split('\0').filter(Boolean) };
|
|
397
|
+
} catch (e) {
|
|
398
|
+
return { error: String(e.stderr || e.message || 'git diff failed').trim().split('\n')[0] };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** File text, or `{ skipped }` when it is binary or larger than 2 MB (said, never silently dropped). */
|
|
403
|
+
export function readText(abs) {
|
|
404
|
+
try {
|
|
405
|
+
if (lstatSync(abs).size > 2 * 1024 * 1024) return { skipped: 'larger than 2 MB' };
|
|
406
|
+
const buf = readFileSync(abs);
|
|
407
|
+
if (buf.subarray(0, 8000).includes(0)) return { skipped: 'binary', quiet: true };
|
|
408
|
+
return { text: buf.toString('utf8').replace(/^\uFEFF/, '') };
|
|
409
|
+
} catch (e) {
|
|
410
|
+
return { skipped: `unreadable (${e.code || e.message})` };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function extOfPath(p) {
|
|
415
|
+
const base = String(p).replace(/\\/g, '/').split('/').pop();
|
|
416
|
+
const i = base.lastIndexOf('.');
|
|
417
|
+
return i > 0 ? base.slice(i + 1).toLowerCase() : '';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Is this module the one being run (not imported)? Resolves symlinks so `node scripts/x.mjs` and a symlinked path agree. */
|
|
421
|
+
export function isMain(metaUrl) {
|
|
422
|
+
try {
|
|
423
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(metaUrl));
|
|
424
|
+
} catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export const indentOf = (l) => l.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length;
|
|
430
|
+
|
|
431
|
+
/** Indented block that follows line `startLine` (0-based index into `lines`): every following line that is blank or indented deeper than `indent`. */
|
|
432
|
+
export function indentBlock(lines, startLine, indent) {
|
|
433
|
+
let end = startLine + 1;
|
|
434
|
+
let lastBody = startLine;
|
|
435
|
+
while (end < lines.length) {
|
|
436
|
+
const l = lines[end];
|
|
437
|
+
if (l.trim() === '') { end++; continue; }
|
|
438
|
+
const ind = indentOf(l);
|
|
439
|
+
if (ind <= indent) break;
|
|
440
|
+
lastBody = end;
|
|
441
|
+
end++;
|
|
442
|
+
}
|
|
443
|
+
return { first: startLine + 1, last: lastBody };
|
|
444
|
+
}
|
|
445
|
+
// ---- END SHARED ENGINE ----
|
|
446
|
+
|
|
447
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
448
|
+
// Stub rules
|
|
449
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
450
|
+
|
|
451
|
+
const MARKER_RE = /WARNING:\s*STUB/;
|
|
452
|
+
const DECLARED_RE = /\bSTUB\b|COVERAGE_EXEMPT/;
|
|
453
|
+
|
|
454
|
+
const NOT_IMPLEMENTED = [
|
|
455
|
+
/\braise\s+NotImplementedError\b/g,
|
|
456
|
+
/\bthrow\s+new\s+\w*NotImplemented\w*/g,
|
|
457
|
+
/\bthrow\s+new\s+(?:Error|\w*Exception)\s*\(\s*['"`]\s*(?:not\s+(?:yet\s+)?implemented|todo|unimplemented)/gi,
|
|
458
|
+
/\b(?:todo|unimplemented)!\s*\(/g,
|
|
459
|
+
/\bTODO\s*\(\s*(?:"[^"\n]*")?\s*\)/g,
|
|
460
|
+
/\bpanic\(\s*"\s*(?:not\s+(?:yet\s+)?implemented|todo|unimplemented)/gi,
|
|
461
|
+
/\bfatalError\(\s*"\s*(?:not\s+(?:yet\s+)?implemented|todo)/gi
|
|
462
|
+
];
|
|
463
|
+
|
|
464
|
+
const EMPTY_FN = {
|
|
465
|
+
js: [
|
|
466
|
+
/\bfunction\s*\*?\s*(\w+)\s*\([^)]*\)\s*(?::\s*[^{;\n]+?)?\s*\{\s*\}/g,
|
|
467
|
+
/\b(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*(?::\s*[^=\n]+?)?=>\s*\{\s*\}/g
|
|
468
|
+
],
|
|
469
|
+
php: [/\bfunction\s+(\w+)\s*\([^)]*\)\s*(?::\s*\??[\w\\]+\s*)?\{\s*\}/g],
|
|
470
|
+
go: [/\bfunc\s+(?:\([^)]*\)\s*)?(\w+)\s*\([^)]*\)\s*(?:\([^)]*\)|[\w.*[\]]+)?\s*\{\s*\}/g],
|
|
471
|
+
rust: [/\bfn\s+(\w+)\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?:->\s*[^{;]+?)?\s*\{\s*\}/g],
|
|
472
|
+
ruby: [/\bdef\s+(\w+[?!=]?)(?:\([^)]*\))?[ \t]*\n\s*end\b/g]
|
|
473
|
+
};
|
|
474
|
+
const IGNORED_NAMES = /^(?:noop|no_?op|_+|initialize)$/i;
|
|
475
|
+
|
|
476
|
+
const declaredNear = (lines, lineNo) => {
|
|
477
|
+
for (let k = Math.max(0, lineNo - 4); k <= lineNo - 1; k++) if (DECLARED_RE.test(lines[k] || '')) return true;
|
|
478
|
+
return false;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* @returns {{ family: string|null, emptyRule: boolean, findings: { rule: string, line: number, name: string, detail: string }[] }}
|
|
483
|
+
*/
|
|
484
|
+
export function analyzeStubText(text, ext) {
|
|
485
|
+
const family = FAMILY_BY_EXT[ext] || null;
|
|
486
|
+
const { noComments, noStrings } = maskSource(text, family || 'other');
|
|
487
|
+
const lineOf = lineIndex(text);
|
|
488
|
+
const lines = text.split('\n');
|
|
489
|
+
const findings = [];
|
|
490
|
+
|
|
491
|
+
// 1. declared placeholders: the STUB marker itself
|
|
492
|
+
lines.forEach((l, i) => {
|
|
493
|
+
if (MARKER_RE.test(l)) {
|
|
494
|
+
findings.push({ rule: 'stub-marker', line: i + 1, name: l.trim().slice(0, 80), detail: 'placeholder marked STUB — must be removed before UAT/production' });
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// 2. silent "not implemented" bodies
|
|
499
|
+
for (const re of NOT_IMPLEMENTED) {
|
|
500
|
+
const r = new RegExp(re.source, re.flags);
|
|
501
|
+
let m;
|
|
502
|
+
while ((m = r.exec(noComments)) !== null) {
|
|
503
|
+
const line = lineOf(m.index);
|
|
504
|
+
if (declaredNear(lines, line)) continue;
|
|
505
|
+
if (/@(?:abc\.)?abstractmethod\b|@overload\b/.test(lines.slice(Math.max(0, line - 4), line - 1).join('\n'))) continue;
|
|
506
|
+
findings.push({ rule: 'not-implemented', line, name: m[0].trim().slice(0, 60), detail: 'a body that says it is not implemented, with no STUB / COVERAGE_EXEMPT marker beside it' });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// 3. silent empty functions (languages with a rule)
|
|
511
|
+
const emptyRule = Boolean(family && (EMPTY_FN[family] || family === 'python'));
|
|
512
|
+
if (family && EMPTY_FN[family]) {
|
|
513
|
+
for (const re of EMPTY_FN[family]) {
|
|
514
|
+
const r = new RegExp(re.source, re.flags);
|
|
515
|
+
let m;
|
|
516
|
+
while ((m = r.exec(noStrings)) !== null) {
|
|
517
|
+
const line = lineOf(m.index);
|
|
518
|
+
if (IGNORED_NAMES.test(m[1]) || declaredNear(lines, line)) continue;
|
|
519
|
+
findings.push({ rule: 'empty-function', line, name: m[1], detail: 'the function body is empty, with no STUB / COVERAGE_EXEMPT marker beside it' });
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (family === 'python') {
|
|
524
|
+
const ns = noStrings.split('\n');
|
|
525
|
+
for (let li = 0; li < ns.length; li++) {
|
|
526
|
+
const m = /^([ \t]*)(?:async\s+)?def\s+(\w+)\s*\(/.exec(ns[li]);
|
|
527
|
+
if (!m || IGNORED_NAMES.test(m[2]) || /^__\w+__$/.test(m[2])) continue;
|
|
528
|
+
const indent = indentOf(m[1] + 'x') - 1;
|
|
529
|
+
// the signature may span lines; the body starts after the line that ends with ':'
|
|
530
|
+
let sig = li;
|
|
531
|
+
while (sig < ns.length - 1 && !/:\s*$/.test(ns[sig]) && !/:\s*\S/.test(ns[sig].slice(ns[sig].lastIndexOf(')')))) sig++;
|
|
532
|
+
const { first, last } = indentBlock(ns, sig, indent);
|
|
533
|
+
const inline = ns[sig].slice(ns[sig].lastIndexOf(')')).replace(/^\)[^:]*:/, '').trim();
|
|
534
|
+
const body = [inline, ...ns.slice(first, last + 1)].map((s) => s.trim()).filter(Boolean);
|
|
535
|
+
const empty = body.length === 0 || body.every((s) => s === 'pass' || s === '...');
|
|
536
|
+
if (!empty) continue;
|
|
537
|
+
if (declaredNear(lines, li + 1)) continue;
|
|
538
|
+
const decorators = lines.slice(Math.max(0, li - 4), li).join('\n');
|
|
539
|
+
if (/@(?:abc\.)?abstractmethod\b|@overload\b/.test(decorators)) continue;
|
|
540
|
+
findings.push({ rule: 'empty-function', line: li + 1, name: m[2], detail: 'the function body is empty (or only `pass` / `...`), with no STUB / COVERAGE_EXEMPT marker beside it' });
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
findings.sort((a, b) => a.line - b.line);
|
|
544
|
+
return { family, emptyRule, findings };
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
548
|
+
// Self-test — runs on EVERY invocation, before a single project file is read.
|
|
549
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
550
|
+
|
|
551
|
+
const SELF_TEST = [
|
|
552
|
+
{ ext: 'js', rules: ['empty-function'], text: 'function save(order) {}\n' },
|
|
553
|
+
{ ext: 'js', rules: ['empty-function'], text: 'const charge = async (card) => {\n}\n' },
|
|
554
|
+
{ ext: 'js', rules: [], text: 'function noop() {}\nfunction add(a, b) { return a + b; }\n' },
|
|
555
|
+
{ ext: 'js', rules: ['stub-marker'], text: '// WARNING: STUB — Remove before UAT\nasync function validatePayment(card) {}\n' },
|
|
556
|
+
{ ext: 'js', rules: [], text: '// COVERAGE_EXEMPT: hardware hook point, nothing to do on this platform\nfunction onTick() {}\n' },
|
|
557
|
+
{ ext: 'py', rules: ['not-implemented'], text: 'def charge(card):\n raise NotImplementedError\n' },
|
|
558
|
+
{ ext: 'py', rules: ['empty-function'], text: 'def save(order):\n pass\n' },
|
|
559
|
+
{ ext: 'py', rules: [], text: 'class A:\n @abstractmethod\n def run(self):\n raise NotImplementedError\n\n def __init__(self):\n pass\n\n\ndef add(a, b):\n return a + b\n' },
|
|
560
|
+
{ ext: 'go', rules: ['empty-function'], text: 'func Charge(card Card) error {}\n' },
|
|
561
|
+
{ ext: 'rs', rules: ['not-implemented'], text: 'fn charge() {\n todo!()\n}\n' },
|
|
562
|
+
{ ext: 'rs', rules: [], text: 'fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n' },
|
|
563
|
+
{ ext: 'zig', rules: ['not-implemented'], text: 'fn charge() void {\n unimplemented!()\n}\n' }
|
|
564
|
+
];
|
|
565
|
+
|
|
566
|
+
/** @returns {string[]} what went wrong; empty = the scanner behaves on every known sample */
|
|
567
|
+
export function selfTest() {
|
|
568
|
+
const problems = [];
|
|
569
|
+
for (const [i, s] of SELF_TEST.entries()) {
|
|
570
|
+
const got = analyzeStubText(s.text, s.ext).findings.map((f) => f.rule).sort();
|
|
571
|
+
const want = [...s.rules].sort();
|
|
572
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) {
|
|
573
|
+
problems.push(`self-test #${i} (.${s.ext}): expected [${want.join(', ')}], got [${got.join(', ')}]`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return problems;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
580
|
+
// Run
|
|
581
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
582
|
+
|
|
583
|
+
// This file spells out the very markers it looks for (in comments and in its self-test), so it
|
|
584
|
+
// must not scan itself.
|
|
585
|
+
const SELF = (() => { try { return realpathSync(fileURLToPath(import.meta.url)); } catch { return null; } })();
|
|
586
|
+
const isSelf = (abs) => { try { return SELF !== null && realpathSync(abs) === SELF; } catch { return false; } };
|
|
587
|
+
|
|
588
|
+
export function scan(root, { staged = false } = {}) {
|
|
589
|
+
const { policy, problems } = loadPolicy(root);
|
|
590
|
+
let files;
|
|
591
|
+
if (staged) {
|
|
592
|
+
const s = stagedFiles(root);
|
|
593
|
+
if (s.error) return { error: `cannot read the staged files: ${s.error}`, problems };
|
|
594
|
+
files = s.files;
|
|
595
|
+
} else {
|
|
596
|
+
files = walkFiles(root, policy);
|
|
597
|
+
}
|
|
598
|
+
const result = { mode: staged ? 'staged' : 'all', problems, files: 0, findings: [], noEmptyRule: new Set(), unreadable: [] };
|
|
599
|
+
for (const rel of files) {
|
|
600
|
+
const kind = classifyPath(rel, policy).kind;
|
|
601
|
+
if (kind !== 'source' && kind !== 'unclassified') continue;
|
|
602
|
+
if (isSelf(join(root, rel))) continue;
|
|
603
|
+
const ext = extOfPath(rel);
|
|
604
|
+
const r = readText(join(root, rel));
|
|
605
|
+
if (r.skipped) { if (!r.quiet) result.unreadable.push(`${rel} (${r.skipped})`); continue; }
|
|
606
|
+
const a = analyzeStubText(r.text, ext);
|
|
607
|
+
result.files++;
|
|
608
|
+
if (!a.emptyRule) result.noEmptyRule.add(ext ? `.${ext}` : '(no extension)');
|
|
609
|
+
for (const f of a.findings) result.findings.push({ file: rel, ...f });
|
|
610
|
+
}
|
|
611
|
+
result.noEmptyRule = [...result.noEmptyRule].sort();
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function report(r) {
|
|
616
|
+
const out = [];
|
|
617
|
+
for (const p of r.problems) out.push(` ! ${p}`);
|
|
618
|
+
out.push(`check-stubs: scanned ${r.files} source file(s) (${r.mode === 'staged' ? 'staged files only' : 'whole project'})`);
|
|
619
|
+
for (const f of r.findings) out.push(` ✗ ${f.file}:${f.line} ${f.rule} ${f.name} — ${f.detail}`);
|
|
620
|
+
if (r.noEmptyRule.length > 0) {
|
|
621
|
+
out.push(` · empty-function scan has no rule for: ${r.noEmptyRule.join(' ')} (markers and "not implemented" bodies were still searched)`);
|
|
622
|
+
}
|
|
623
|
+
for (const u of r.unreadable) out.push(` · NOT scanned — ${u}`);
|
|
624
|
+
out.push(r.findings.length > 0 ? `RESULT: ${r.findings.length} finding(s)` : 'RESULT: no stubs found among the files scanned');
|
|
625
|
+
return out.join('\n');
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export function main(argv, root = process.cwd()) {
|
|
629
|
+
const bad = selfTest();
|
|
630
|
+
if (bad.length > 0) {
|
|
631
|
+
console.error('check-stubs: the scanner failed its own self-test, so its result would mean nothing:');
|
|
632
|
+
for (const b of bad) console.error(` ${b}`);
|
|
633
|
+
return 2;
|
|
634
|
+
}
|
|
635
|
+
const r = scan(root, { staged: argv.includes('--staged') });
|
|
636
|
+
if (r.error) {
|
|
637
|
+
console.error(`check-stubs: ${r.error}`);
|
|
638
|
+
return 2;
|
|
639
|
+
}
|
|
640
|
+
console.log(argv.includes('--json') ? JSON.stringify(r, null, 2) : report(r));
|
|
641
|
+
return r.findings.length > 0 ? 1 : 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (isMain(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-dev-standards",
|
|
3
|
-
"version": "6.14.0-beta.
|
|
3
|
+
"version": "6.14.0-beta.5",
|
|
4
4
|
"description": "CLI tool for adopting Universal Development Standards",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"documentation",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"devDependencies": {
|
|
74
74
|
"@eslint/js": "^10.0.1",
|
|
75
75
|
"@vitest/coverage-v8": "^5.0.0",
|
|
76
|
-
"eslint": "10.
|
|
76
|
+
"eslint": "10.12.0",
|
|
77
77
|
"globals": "17.12.0",
|
|
78
78
|
"husky": "^9.1.7",
|
|
79
79
|
"lint-staged": "^17.0.3",
|