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.
Files changed (41) hide show
  1. package/bin/uds.js +7 -1
  2. package/bundled/ai/standards/full-coverage-testing.ai.yaml +46 -5
  3. package/bundled/core/full-coverage-testing.md +57 -3
  4. package/bundled/locales/zh-CN/CHANGELOG.md +29 -2
  5. package/bundled/locales/zh-CN/README.md +1 -1
  6. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  7. package/bundled/locales/zh-CN/core/full-coverage-testing.md +61 -7
  8. package/bundled/locales/zh-CN/docs/CHEATSHEET.md +3 -1
  9. package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +8 -5
  10. package/bundled/locales/zh-TW/CHANGELOG.md +29 -2
  11. package/bundled/locales/zh-TW/README.md +1 -1
  12. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  13. package/bundled/locales/zh-TW/core/full-coverage-testing.md +61 -7
  14. package/bundled/locales/zh-TW/docs/CHEATSHEET.md +3 -1
  15. package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +8 -5
  16. package/bundled/templates/gates/check-anti-fake-tests.mjs +991 -0
  17. package/bundled/templates/gates/check-stubs.mjs +644 -0
  18. package/package.json +2 -2
  19. package/src/commands/audit.js +11 -0
  20. package/src/commands/check.js +124 -24
  21. package/src/commands/init.js +16 -0
  22. package/src/commands/update.js +180 -19
  23. package/src/core/install-records.js +2 -1
  24. package/src/i18n/messages.js +50 -9
  25. package/src/reconciler/backup-manager.js +418 -82
  26. package/src/reconciler/index.js +27 -5
  27. package/src/reconciler/install-roots.js +90 -0
  28. package/src/reconciler/plan-executor.js +23 -2
  29. package/src/uninstallers/hook-uninstaller.js +2 -1
  30. package/src/utils/command-hash-ownership.js +103 -0
  31. package/src/utils/copier.js +21 -1
  32. package/src/utils/gate-scripts.js +141 -0
  33. package/src/utils/health-scorer.js +10 -7
  34. package/src/utils/skill-hash-ownership.js +64 -0
  35. package/src/utils/skills-installer.js +12 -1
  36. package/src/utils/test-change-check.js +160 -0
  37. package/src/utils/test-policy.js +214 -0
  38. package/src/utils/update-summary.js +29 -0
  39. package/standards-registry.json +7 -7
  40. package/bundled/extensions/languages/php/fat-free-patterns.md +0 -915
  41. 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.4",
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.10.0",
76
+ "eslint": "10.12.0",
77
77
  "globals": "17.12.0",
78
78
  "husky": "^9.1.7",
79
79
  "lint-staged": "^17.0.3",