universal-dev-standards 6.3.6 → 6.3.8
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/bundled/ai/standards/design-document-standards.ai.yaml +129 -1
- package/bundled/ai/standards/estimation-standards.ai.yaml +123 -1
- package/bundled/ai/standards/privacy-standards.ai.yaml +148 -2
- package/bundled/ai/standards/supply-chain-security-standards.ai.yaml +157 -9
- package/bundled/locales/zh-CN/CHANGELOG.md +19 -3
- package/bundled/locales/zh-CN/README.md +1 -1
- package/bundled/locales/zh-TW/CHANGELOG.md +19 -3
- package/bundled/locales/zh-TW/README.md +1 -1
- package/package.json +1 -4
- package/src/commands/check.js +18 -3
- package/src/commands/config.js +5 -1
- package/src/commands/deps.js +44 -2
- package/src/commands/update.js +16 -3
- package/src/utils/dependency-resolution.js +128 -8
- package/src/utils/integration-generator.js +103 -27
- package/src/utils/registry.js +45 -0
- package/standards-registry.json +7 -7
- package/src/schemas/standard.schema.json +0 -117
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
source: ../../CHANGELOG.md
|
|
3
|
-
source_version: 6.3.
|
|
4
|
-
translation_version: 6.3.
|
|
5
|
-
last_synced: 2026-08-
|
|
3
|
+
source_version: 6.3.8
|
|
4
|
+
translation_version: 6.3.8
|
|
5
|
+
last_synced: 2026-08-08
|
|
6
6
|
status: current
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -17,6 +17,22 @@ status: current
|
|
|
17
17
|
|
|
18
18
|
## [Unreleased]
|
|
19
19
|
|
|
20
|
+
## [6.3.8] - 2026-08-08
|
|
21
|
+
|
|
22
|
+
### 修正
|
|
23
|
+
|
|
24
|
+
- **`uds deps` 對一個它沒有檢查過的集合打了綠勾。** 對一個沒有執行期相依、且使用 pnpm lockfile 的專案執行時,它印出 `0 runtime dependencies checked`,接著 `no package-lock.json — nothing to compare the registry against`,接著 `✓ every dependency resolves to the version you test against`,然後 exit 0。兩項事實都為真;合起來卻宣稱一個這個指令讀不了的 repo 已被檢查而且沒問題,而任何接在這個 exit code 上的閘門都會同意。`clean` 的定義是三個空清單的合取,在空集合上恆真——而涵蓋它的測試正是以「分母會跟著結論一起走」為由斷言了這件事。分母確實跟著走了,卻什麼也沒改變:打勾緊接其後,而 exit code 完全沒有帶上那個計數。**印出分母不足以阻止空集合被讀成安心;拒絕給出結論才可以。** 同時修正該訊息的後半:該專案有一份完全正常的 `pnpm-lock.yaml`,而被告知「你沒有 lockfile」正是讀者判定工具搞錯、從此不再讀它的方式。指令現在會說出找到的是什麼、以及自己讀的是哪一種格式。
|
|
25
|
+
- **registry ID 不是檔名,而有八個地方把它當成檔名用。** manifest 的 `standards` 陣列是刻意混合的:core 標準自 v3.4.0 起改為 registry ID,option 條目維持其上游來源路徑,因為 option 沒有 ID。而每個消費端都對兩者一律套 `basename()`——對路徑正確,對 ID 是 no-op。`error-code-standards` 安裝為 `error-codes.ai.yaml`、`logging-standards` 為 `logging.ai.yaml`、`ai-agreement` 為 `ai-agreement-standards.ai.yaml`;多數 ID 確實等於它的 basename,這正是它能存活的原因。同一個錯誤導出三種失效:minimal 模式印出 `.standards/<id>`,使**某採用者 AGENTS.md 的七十個路徑中有七個指不到東西**,而它們正下方那一行寫著「你必須讀取並遵循 `.standards/` 裡的標準」;索引區塊用 `.ai.yaml` 後綴過濾,而沒有任何 ID 帶這個後綴,於是**所有核心標準都被丟掉**,同一個採用者先前的區塊列了七個 option、六十三項核心標準一個也沒有;任務對應表以檔名為鍵,ID 一個也對不上,該標準就安靜地沒有對應。解析現在是一個匯出的函式,八個呼叫點共用,而解析不出的條目會列在清單下方回報,不會被印成路徑。
|
|
26
|
+
- **那個專門用來抓這種漂移的檢查,帶著同一個缺陷。** `AGENTS.md Standards Sync` 對一份七十項的 manifest 回報 `7/7`:`.ai.yaml` 過濾只留下七個 option 條目,七個都在,於是打勾——升級前六十三項標準不在區塊裡時打勾,升級後區塊裡有七個死路徑時也打勾。對它所量測對象的九成視而不見,而且全程綠燈。在同一個專案上現在是 67/67,而手動弄壞一個路徑會回報 66/67 並指名該檔。它需要的那份對照,早就建在它上方一百七十行處,註解甚至指名了那個案例。
|
|
27
|
+
- **產生器仍把 6.0.0 移除的路徑寫進採用者的指令檔。** `MIGRATION-v6` §2 移除了八個機器可讀標準,它們的執行期已移往採用層;三行 `Reference:` 與一筆 MUST 等級的任務對應仍指向 `.standards/workflow-enforcement.ai.yaml`。人類可讀的 `core/workflow-enforcement.md` 是刻意保留在上游的,但採用者收到的是 `.standards/` 而非 `core/`,所以它不是替代路徑——這幾行是刪除而非改指。控制該段落的判斷式比對的檔名,自 3.4.0 起沒有任何 manifest 持有,於是那段落對兩個仍宣告該標準的專案早已悄悄不再產生;現在兩種形式都比對。
|
|
28
|
+
|
|
29
|
+
## [6.3.7] - 2026-08-07
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **`uds deps` 只讀 root 的 manifest,於是 monorepo 得到一個關於自己一部分的乾淨答案。** npm workspaces 會把宣告放在不只一份 `package.json`,而這個指令只看了其中一份。實測於一個真實專案:回報 34 個相依,實際宣告 47 個——**在 workspace 裡的那 13 個是隱形的,而其中一個帶著 high 等級的公告**。**一個沒有帶著自己範圍的計數,與一個完整的計數無從分辨**,而那正是這個指令存在要回報的失效。現在會從 `workspaces` 欄位展開、檢查每一份 manifest,並在報告中印出納入了哪些 workspace,讓分母自己帶著範圍。每一列漂移都標明它來自哪個 workspace——否則讀者知道某個套件漂移了,卻不知道該去改哪一份 `package.json`。
|
|
34
|
+
- 三個細節決定了「有涵蓋 workspaces」與「看起來有涵蓋」的差別。lockfile 的條目可能被 hoist 到 root,**也可能**巢狀在 workspace 底下,所以兩處都查;只查一處會把另一處回報成「not present in package-lock.json」,而**一個被捏造出來的未知讀起來像一個發現**。workspace 相依於另一個 workspace 時是檔案連結而非已發布套件,因此跳過而不查詢——問 npm 會得到 404 並被記成 unverifiable。以及,比「最後一段結尾一個 `*`」更複雜的 `workspaces` 模式現在會**大聲失敗**而非匹配一個子集:**靜靜地涵蓋得比作者本意少,是同一個缺陷換個地方發生**。
|
|
35
|
+
|
|
20
36
|
## [6.3.6] - 2026-08-07
|
|
21
37
|
|
|
22
38
|
### Fixed
|
|
@@ -15,7 +15,7 @@ status: current
|
|
|
15
15
|
|
|
16
16
|
> **語言**: [English](../../README.md) | 繁體中文 | [简体中文](../zh-CN/README.md)
|
|
17
17
|
|
|
18
|
-
**版本**: 6.3.
|
|
18
|
+
**版本**: 6.3.8 | **發布日期**: 2026-07-31 | **授權**: [雙重授權](../../LICENSE) (CC BY 4.0 + MIT)
|
|
19
19
|
|
|
20
20
|
語言無關、框架無關的軟體專案文件標準。透過 AI 原生工作流,確保不同技術堆疊之間的一致性、品質和可維護性。
|
|
21
21
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "universal-dev-standards",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.8",
|
|
4
4
|
"description": "CLI tool for adopting Universal Development Standards",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"documentation",
|
|
@@ -69,10 +69,7 @@
|
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@eslint/js": "^10.0.1",
|
|
71
71
|
"@vitest/coverage-v8": "^4.1.5",
|
|
72
|
-
"ajv": "^8.20.0",
|
|
73
|
-
"ajv-formats": "^3.0.1",
|
|
74
72
|
"eslint": "10.7.0",
|
|
75
|
-
"glob": "^13.0.1",
|
|
76
73
|
"globals": "17.7.0",
|
|
77
74
|
"husky": "^9.1.7",
|
|
78
75
|
"lint-staged": "^17.0.3",
|
package/src/commands/check.js
CHANGED
|
@@ -7,8 +7,7 @@ import { execSync } from 'child_process';
|
|
|
7
7
|
import { readManifest, writeManifest, isInitialized, copyStandard, copyIntegration } from '../utils/copier.js';
|
|
8
8
|
import {
|
|
9
9
|
getAllStandards,
|
|
10
|
-
getRepositoryInfo
|
|
11
|
-
} from '../utils/registry.js';
|
|
10
|
+
getRepositoryInfo, resolveStandardFilename } from '../utils/registry.js';
|
|
12
11
|
import {
|
|
13
12
|
computeFileHash,
|
|
14
13
|
compareFileHash,
|
|
@@ -1244,7 +1243,23 @@ function checkAgentsMdSync(manifest, projectPath, msg) {
|
|
|
1244
1243
|
}
|
|
1245
1244
|
|
|
1246
1245
|
const content = readFileSync(agentsMdPath, 'utf-8');
|
|
1247
|
-
|
|
1246
|
+
// Resolved through the registry, not basename()d.
|
|
1247
|
+
//
|
|
1248
|
+
// This check reported "standards synced (7/7)" for a project with seventy
|
|
1249
|
+
// standards in its manifest. basename() leaves a registry ID unchanged, the
|
|
1250
|
+
// `.ai.yaml` filter below then drops every ID, and only the seven option
|
|
1251
|
+
// entries — which are stored as paths — survived to become the denominator.
|
|
1252
|
+
// Seven of seven were present, so it ticked, both before the AGENTS.md block
|
|
1253
|
+
// listed the other sixty-three and after, when seven of its paths pointed at
|
|
1254
|
+
// nothing. A drift check blind to ninety percent of the content.
|
|
1255
|
+
//
|
|
1256
|
+
// The AI-tool integration check ~170 lines above already builds this exact
|
|
1257
|
+
// mapping and its comment names the exact case (`error-code-standards` ->
|
|
1258
|
+
// `error-codes.ai.yaml`). The knowledge was in the file; it had not reached
|
|
1259
|
+
// its sibling.
|
|
1260
|
+
const installedStandards = (manifest.standards || [])
|
|
1261
|
+
.map(entry => resolveStandardFilename(entry, manifest.format || 'ai'))
|
|
1262
|
+
.filter(Boolean);
|
|
1248
1263
|
|
|
1249
1264
|
// Same as the AI-tool integration check above: an index-mode block declares a
|
|
1250
1265
|
// count instead of listing names, so the name grep below cannot apply to it.
|
package/src/commands/config.js
CHANGED
|
@@ -917,7 +917,10 @@ export async function runProjectConfiguration(options) {
|
|
|
917
917
|
const intSpinner = ora(msgObj.regeneratingIntegrations).start();
|
|
918
918
|
|
|
919
919
|
// Build installed standards list
|
|
920
|
-
|
|
920
|
+
// Raw, not basename()d. Resolution needs the registry (an ID is not a
|
|
921
|
+
// filename) and the `/options/` segment (it is what classifies an entry).
|
|
922
|
+
// basename() removes both. See resolveStandardFilename.
|
|
923
|
+
const installedStandardsList = manifest.standards || [];
|
|
921
924
|
|
|
922
925
|
// Determine language setting
|
|
923
926
|
let commonLanguage = 'en';
|
|
@@ -941,6 +944,7 @@ export async function runProjectConfiguration(options) {
|
|
|
941
944
|
categories: ['anti-hallucination', 'commit-standards', 'code-review'],
|
|
942
945
|
language: commonLanguage,
|
|
943
946
|
installedStandards: installedStandardsList,
|
|
947
|
+
standardsFormat: manifest.format || 'ai',
|
|
944
948
|
contentMode: newContentMode,
|
|
945
949
|
// Pass output_language for dynamic commit standards generation
|
|
946
950
|
outputLanguage: newOptions.output_language || 'english'
|
package/src/commands/deps.js
CHANGED
|
@@ -37,8 +37,47 @@ export function render(result) {
|
|
|
37
37
|
lines.push('');
|
|
38
38
|
lines.push(`${label} — ${result.examined} runtime dependenc${result.examined === 1 ? 'y' : 'ies'} checked`);
|
|
39
39
|
|
|
40
|
+
// The scope of the count, printed whenever it is more than the root. A
|
|
41
|
+
// denominator without its scope is what let this command report "34
|
|
42
|
+
// dependencies checked" for a repository that declared 47 across two
|
|
43
|
+
// manifests — the reader has no way to tell a complete answer from a subset.
|
|
44
|
+
const wsCount = result.workspaces?.length ?? 0;
|
|
45
|
+
if (wsCount > 0) {
|
|
46
|
+
lines.push(chalk.dim(` across the root and ${wsCount} workspace${wsCount === 1 ? '' : 's'}: ${result.workspaces.join(', ')}`));
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
if (!result.hasLockfile) {
|
|
41
|
-
lines.push(
|
|
50
|
+
lines.push(
|
|
51
|
+
result.foreignLockfile
|
|
52
|
+
? chalk.yellow(
|
|
53
|
+
` found ${result.foreignLockfile}, and this command reads package-lock.json — the` +
|
|
54
|
+
' tested column cannot be filled in',
|
|
55
|
+
)
|
|
56
|
+
: chalk.yellow(' no package-lock.json — nothing to compare the registry against'),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Zero examined is reported before anything else can be said about the
|
|
61
|
+
// result, because everything else would be said about nothing. The three
|
|
62
|
+
// problem lists are empty here for the same reason an empty room has no
|
|
63
|
+
// untidy corners, and the tick below used to fire on exactly that.
|
|
64
|
+
if (result.examined === 0) {
|
|
65
|
+
lines.push('');
|
|
66
|
+
lines.push(chalk.yellow(' Nothing was examined, so nothing is being asserted.'));
|
|
67
|
+
lines.push(
|
|
68
|
+
chalk.dim(
|
|
69
|
+
result.hasLockfile
|
|
70
|
+
? ' This package declares no runtime dependencies. If you expected some,'
|
|
71
|
+
: ' Without a readable lockfile there is no tested column. If you expected',
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
lines.push(
|
|
75
|
+
chalk.dim(
|
|
76
|
+
result.hasLockfile
|
|
77
|
+
? ' check that they are in "dependencies" rather than "devDependencies".'
|
|
78
|
+
: ' dependencies here, check the manifest and the lockfile format.',
|
|
79
|
+
),
|
|
80
|
+
);
|
|
42
81
|
}
|
|
43
82
|
|
|
44
83
|
if (result.drifted.length > 0) {
|
|
@@ -52,9 +91,12 @@ export function render(result) {
|
|
|
52
91
|
// underneath it" shape that R4 rewrote the supply-chain standard to stop.
|
|
53
92
|
lines.push(chalk.yellow(` ${result.drifted.length} tested ≠ resolves:`));
|
|
54
93
|
for (const d of result.drifted) {
|
|
94
|
+
// The workspace is part of the finding, not decoration: without it the
|
|
95
|
+
// reader knows a package drifted but not which package.json to edit.
|
|
96
|
+
const where = d.workspaceDir ? chalk.dim(` [${d.workspaceDir}]`) : '';
|
|
55
97
|
lines.push(
|
|
56
98
|
` ${chalk.bold(d.name)} ${chalk.dim(d.range)}` +
|
|
57
|
-
` tested=${chalk.cyan(d.locked)} resolves=${chalk.yellow(d.resolved)}`
|
|
99
|
+
` tested=${chalk.cyan(d.locked)} resolves=${chalk.yellow(d.resolved)}${where}`
|
|
58
100
|
);
|
|
59
101
|
}
|
|
60
102
|
lines.push('');
|
package/src/commands/update.js
CHANGED
|
@@ -589,7 +589,10 @@ export async function updateCommand(options) {
|
|
|
589
589
|
const intSpinner = ora(msg.syncingIntegrations).start();
|
|
590
590
|
|
|
591
591
|
// Build installed standards list
|
|
592
|
-
|
|
592
|
+
// Raw, not basename()d. Resolution needs the registry (an ID is not a
|
|
593
|
+
// filename) and the `/options/` segment (it is what classifies an entry).
|
|
594
|
+
// basename() removes both. See resolveStandardFilename.
|
|
595
|
+
const installedStandardsList = manifest.standards || [];
|
|
593
596
|
|
|
594
597
|
// Determine language setting
|
|
595
598
|
let commonLanguage = 'en';
|
|
@@ -617,6 +620,7 @@ export async function updateCommand(options) {
|
|
|
617
620
|
categories: ['anti-hallucination', 'commit-standards', 'code-review'],
|
|
618
621
|
language: commonLanguage,
|
|
619
622
|
installedStandards: installedStandardsList,
|
|
623
|
+
standardsFormat: manifest.format || 'ai',
|
|
620
624
|
contentMode: resolved.contentMode,
|
|
621
625
|
level: resolved.level,
|
|
622
626
|
// Pass output_language for dynamic commit standards generation
|
|
@@ -645,6 +649,7 @@ export async function updateCommand(options) {
|
|
|
645
649
|
if (manifest.generateAgentsMd && !generatedFiles.has('AGENTS.md')) {
|
|
646
650
|
const summaryConfig = {
|
|
647
651
|
installedStandards: installedStandardsList,
|
|
652
|
+
standardsFormat: manifest.format || 'ai',
|
|
648
653
|
language: manifest.options?.display_language || 'en',
|
|
649
654
|
outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english',
|
|
650
655
|
standardOptions: manifest.options || {}
|
|
@@ -1404,7 +1409,8 @@ export function regenerateIntegrations(projectPath, manifest) {
|
|
|
1404
1409
|
// Regenerate universal AGENTS.md if enabled and not already covered
|
|
1405
1410
|
if (manifest.generateAgentsMd && !generatedFiles.has('AGENTS.md')) {
|
|
1406
1411
|
const summaryConfig = {
|
|
1407
|
-
|
|
1412
|
+
// Raw — writeAgentsMdSummary resolves via the registry now.
|
|
1413
|
+
installedStandards: manifest.standards || [],
|
|
1408
1414
|
language: manifest.options?.display_language || 'en',
|
|
1409
1415
|
outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english',
|
|
1410
1416
|
standardOptions: manifest.options || {}
|
|
@@ -1469,7 +1475,14 @@ async function updateIntegrationsOnly(projectPath, manifest, options = {}) {
|
|
|
1469
1475
|
? 'bilingual'
|
|
1470
1476
|
: (manifest.options?.output_language || manifest.options?.commit_language) === 'traditional-chinese'
|
|
1471
1477
|
? 'zh-tw' : 'en',
|
|
1472
|
-
|
|
1478
|
+
// Passed raw. `basename()` here destroyed the two things the
|
|
1479
|
+
// generator needs: a registry ID cannot be turned back into a
|
|
1480
|
+
// filename once it has been through basename() (it comes out
|
|
1481
|
+
// unchanged, and an ID is not a filename), and an option entry loses
|
|
1482
|
+
// the `/options/` segment that classifies it. Resolution belongs
|
|
1483
|
+
// where the registry is consulted, not at the call site.
|
|
1484
|
+
installedStandards: manifest.standards || [],
|
|
1485
|
+
standardsFormat: manifest.format || 'ai',
|
|
1473
1486
|
contentMode: resolvedMode.contentMode,
|
|
1474
1487
|
level: resolvedMode.level,
|
|
1475
1488
|
outputLanguage: manifest.options?.output_language || manifest.options?.commit_language || 'english'
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
*/
|
|
67
67
|
|
|
68
68
|
import { spawn } from 'node:child_process';
|
|
69
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
69
|
+
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
|
70
70
|
import { join } from 'node:path';
|
|
71
71
|
import semver from 'semver';
|
|
72
72
|
|
|
@@ -200,6 +200,66 @@ function spawnNpm(cwd) {
|
|
|
200
200
|
});
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Expand npm's `workspaces` field into directories that contain a
|
|
205
|
+
* package.json. // implements XSPEC-366 R1 (workspaces)
|
|
206
|
+
*
|
|
207
|
+
* Accepts both spellings — an array, or `{ packages: [...] }` — and supports a
|
|
208
|
+
* trailing `*` in the last segment, which is what `packages/*` needs and is the
|
|
209
|
+
* shape npm's own docs use. A pattern that matches nothing is returned as
|
|
210
|
+
* nothing rather than as an error: an empty `packages/*` is a normal state for
|
|
211
|
+
* a repository that has not added one yet.
|
|
212
|
+
*/
|
|
213
|
+
function expandWorkspaces(root, field) {
|
|
214
|
+
const patterns = Array.isArray(field) ? field : Array.isArray(field?.packages) ? field.packages : [];
|
|
215
|
+
const dirs = [];
|
|
216
|
+
for (const pattern of patterns) {
|
|
217
|
+
if (typeof pattern !== 'string' || pattern.length === 0) continue;
|
|
218
|
+
const star = pattern.indexOf('*');
|
|
219
|
+
if (star === -1) {
|
|
220
|
+
if (existsSync(join(root, pattern, 'package.json'))) dirs.push(pattern);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Only a trailing `*` in the final segment is supported. Anything more
|
|
224
|
+
// exotic is rejected loudly rather than silently matching less than the
|
|
225
|
+
// author meant — a workspace quietly outside the denominator is the defect
|
|
226
|
+
// this module exists to report.
|
|
227
|
+
const prefix = pattern.slice(0, star);
|
|
228
|
+
if (!pattern.endsWith('*') || prefix.includes('*') || (prefix.length > 0 && !prefix.endsWith('/'))) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`unsupported workspaces pattern ${JSON.stringify(pattern)} — only a trailing "*" in the last segment is understood`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const parent = join(root, prefix);
|
|
234
|
+
if (!existsSync(parent)) continue;
|
|
235
|
+
for (const entry of readdirSync(parent, { withFileTypes: true })) {
|
|
236
|
+
if (!entry.isDirectory()) continue;
|
|
237
|
+
const rel = `${prefix}${entry.name}`;
|
|
238
|
+
if (existsSync(join(root, rel, 'package.json'))) dirs.push(rel);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return dirs;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Where the lockfile records this dependency.
|
|
246
|
+
*
|
|
247
|
+
* npm hoists what it can to the root `node_modules` and nests the rest under
|
|
248
|
+
* the workspace, so both have to be tried; checking only one under-reports and
|
|
249
|
+
* calls the miss "unverifiable", which is noise that trains people to ignore
|
|
250
|
+
* the report.
|
|
251
|
+
*/
|
|
252
|
+
function lockedVersionFor(lock, workspaceDir, name) {
|
|
253
|
+
const candidates = workspaceDir
|
|
254
|
+
? [`${workspaceDir}/node_modules/${name}`, `node_modules/${name}`]
|
|
255
|
+
: [`node_modules/${name}`];
|
|
256
|
+
for (const key of candidates) {
|
|
257
|
+
const entry = lock?.packages?.[key];
|
|
258
|
+
if (entry?.version) return entry.version;
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
203
263
|
/** Map over `items` with a bounded number of concurrent workers. */
|
|
204
264
|
async function mapWithConcurrency(items, limit, worker) {
|
|
205
265
|
const results = new Array(items.length);
|
|
@@ -238,16 +298,46 @@ export async function measureResolutionDrift(root, options = {}) {
|
|
|
238
298
|
const lockPath = join(root, 'package-lock.json');
|
|
239
299
|
const lock = existsSync(lockPath) ? JSON.parse(readFileSync(lockPath, 'utf8')) : null;
|
|
240
300
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
301
|
+
// Which lockfile is present, when it is not npm's. Only package-lock.json is
|
|
302
|
+
// understood, and "not understood" has to be reported as a different thing
|
|
303
|
+
// from "not there" — a pnpm project told it had no lockfile will reasonably
|
|
304
|
+
// conclude the command is confused, and stop reading.
|
|
305
|
+
const foreignLockfile =
|
|
306
|
+
lock === null
|
|
307
|
+
? ['pnpm-lock.yaml', 'yarn.lock', 'bun.lockb', 'bun.lock'].find((f) => existsSync(join(root, f))) ?? null
|
|
308
|
+
: null;
|
|
309
|
+
|
|
310
|
+
// Workspaces are examined too. A monorepo that declares its shipped
|
|
311
|
+
// front-end in a workspace and its server at the root has two manifests, and
|
|
312
|
+
// reading only the root reports a clean subset as if it were the whole — the
|
|
313
|
+
// failure this module exists to catch, performed by the tool itself.
|
|
314
|
+
const workspaceDirs = expandWorkspaces(root, pkg.workspaces);
|
|
315
|
+
const manifests = [{ dir: null, name: pkg.name ?? null, pkg }];
|
|
316
|
+
for (const dir of workspaceDirs) {
|
|
317
|
+
const wsPkg = JSON.parse(readFileSync(join(root, dir, 'package.json'), 'utf8'));
|
|
318
|
+
manifests.push({ dir, name: wsPkg.name ?? dir, pkg: wsPkg });
|
|
319
|
+
}
|
|
320
|
+
// A workspace depending on a sibling workspace is a file link, not a
|
|
321
|
+
// registry package. Querying npm for it returns 404, which would be recorded
|
|
322
|
+
// as unverifiable — a fabricated unknown, which is worse than a missing one
|
|
323
|
+
// because it looks like a finding.
|
|
324
|
+
const localNames = new Set(manifests.map((m) => m.pkg.name).filter(Boolean));
|
|
325
|
+
|
|
326
|
+
const declared = [];
|
|
327
|
+
for (const { dir, name: workspace, pkg: manifest } of manifests) {
|
|
328
|
+
for (const kind of ['dependencies', 'optionalDependencies']) {
|
|
329
|
+
for (const [name, range] of Object.entries(manifest[kind] ?? {})) {
|
|
330
|
+
if (localNames.has(name)) continue;
|
|
331
|
+
declared.push({ name, range, kind, workspace, workspaceDir: dir });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
245
335
|
|
|
246
336
|
const run = options.run ?? spawnNpm(root);
|
|
247
337
|
const concurrency = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
248
338
|
|
|
249
339
|
const rows = await mapWithConcurrency(declared, concurrency, async (dep) => {
|
|
250
|
-
const locked = lock
|
|
340
|
+
const locked = lockedVersionFor(lock, dep.workspaceDir, dep.name);
|
|
251
341
|
let resolved;
|
|
252
342
|
try {
|
|
253
343
|
resolved = await resolveViaNpm(dep.name, dep.range, run);
|
|
@@ -282,12 +372,42 @@ export async function measureResolutionDrift(root, options = {}) {
|
|
|
282
372
|
root,
|
|
283
373
|
packageName: pkg.name ?? null,
|
|
284
374
|
hasLockfile: lock !== null,
|
|
375
|
+
/**
|
|
376
|
+
* Workspace directories examined alongside the root, so a reader can tell
|
|
377
|
+
* "no workspaces here" from "workspaces were not looked at". The count
|
|
378
|
+
* printed by the report is the denominator, and a denominator without its
|
|
379
|
+
* scope is the shape this module was written to refuse.
|
|
380
|
+
*/
|
|
381
|
+
workspaces: workspaceDirs,
|
|
285
382
|
examined: rows.length,
|
|
286
383
|
consistent,
|
|
287
384
|
drifted,
|
|
288
385
|
unverifiable,
|
|
289
386
|
unpinnedNative,
|
|
290
|
-
/**
|
|
291
|
-
|
|
387
|
+
/**
|
|
388
|
+
* A lockfile in a format this command cannot read, when there is no
|
|
389
|
+
* package-lock.json. `hasLockfile: false` alone reads as "this project has
|
|
390
|
+
* no lockfile", which for a pnpm or yarn project is simply untrue, and the
|
|
391
|
+
* report said so out loud: "no package-lock.json — nothing to compare"
|
|
392
|
+
* printed against a repo holding a perfectly good pnpm-lock.yaml.
|
|
393
|
+
*/
|
|
394
|
+
foreignLockfile,
|
|
395
|
+
/**
|
|
396
|
+
* True when every dependency was checked, agreed, and needs no pinning.
|
|
397
|
+
*
|
|
398
|
+
* `examined === 0` is excluded, and that exclusion is the point. Run
|
|
399
|
+
* against asiaostrich-telemetry-client — zero runtime dependencies, a
|
|
400
|
+
* pnpm lockfile this command cannot read — the three problem lists came
|
|
401
|
+
* back empty and the report printed "✓ every dependency resolves to the
|
|
402
|
+
* version you test against". Nothing had been examined. The tick was
|
|
403
|
+
* true and it was also the exact shape this command exists to refuse:
|
|
404
|
+
* "nothing to check" and "everything checked out" arriving as the same
|
|
405
|
+
* green line.
|
|
406
|
+
*/
|
|
407
|
+
clean:
|
|
408
|
+
rows.length > 0 &&
|
|
409
|
+
drifted.length === 0 &&
|
|
410
|
+
unverifiable.length === 0 &&
|
|
411
|
+
unpinnedNative.length === 0,
|
|
292
412
|
};
|
|
293
413
|
}
|