universal-dev-standards 6.7.5 → 6.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/uds.js +19 -2
- package/bundled/ai/standards/acceptance-criteria-traceability.ai.yaml +14 -2
- package/bundled/ai/standards/adr-standards.ai.yaml +14 -2
- package/bundled/ai/standards/ai-instruction-standards.ai.yaml +6 -6
- package/bundled/ai/standards/code-review.ai.yaml +13 -3
- package/bundled/ai/standards/commit-message.ai.yaml +8 -4
- package/bundled/ai/standards/deferred-item-exit.ai.yaml +225 -0
- package/bundled/ai/standards/feature-discovery-standards.ai.yaml +14 -2
- package/bundled/ai/standards/governance-layer.ai.yaml +128 -2
- package/bundled/ai/standards/logging.ai.yaml +2 -2
- package/bundled/ai/standards/retrospective-standards.ai.yaml +14 -2
- package/bundled/ai/standards/reverse-engineering-standards.ai.yaml +73 -2
- package/bundled/ai/standards/security-standards.ai.yaml +2 -2
- package/bundled/ai/standards/spec-driven-development.ai.yaml +14 -2
- package/bundled/ai/standards/tech-debt-standards.ai.yaml +87 -3
- package/bundled/ai/standards/turn-completion-integrity.ai.yaml +131 -0
- package/bundled/core/acceptance-criteria-traceability.md +5 -2
- package/bundled/core/adr-standards.md +26 -2
- package/bundled/core/ai-instruction-standards.md +9 -7
- package/bundled/core/code-review-checklist.md +5 -2
- package/bundled/core/context-aware-loading.md +1 -1
- package/bundled/core/deferred-item-exit.md +254 -0
- package/bundled/core/feature-discovery-standards.md +5 -1
- package/bundled/core/governance-layer.md +114 -2
- package/bundled/core/retrospective-standards.md +4 -2
- package/bundled/core/reverse-engineering-standards.md +81 -2
- package/bundled/core/spec-driven-development.md +8 -2
- package/bundled/core/tech-debt-standards.md +67 -8
- package/bundled/core/turn-completion-integrity.md +196 -0
- package/bundled/hooks/check-dangerous-cmd.mjs +60 -0
- package/bundled/hooks/check-logging-standard.mjs +59 -0
- package/bundled/hooks/check-turn-completion.mjs +233 -0
- package/bundled/hooks/inject-standards.mjs +183 -0
- package/bundled/hooks/telemetry-wrapper.mjs +77 -0
- package/bundled/hooks/turn-completion/detect.mjs +99 -0
- package/bundled/hooks/turn-completion/locales/en.mjs +159 -0
- package/bundled/hooks/turn-completion/locales/zh-TW.mjs +166 -0
- package/bundled/hooks/validate-commit-msg.mjs +104 -0
- package/bundled/locales/zh-CN/CHANGELOG.md +65 -3
- package/bundled/locales/zh-CN/CLAUDE.md +1 -1
- package/bundled/locales/zh-CN/README.md +7 -7
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/core/adr-standards.md +1 -1
- package/bundled/locales/zh-CN/core/ai-instruction-standards.md +10 -8
- package/bundled/locales/zh-CN/core/governance-layer.md +118 -6
- package/bundled/locales/zh-CN/core/retrospective-standards.md +1 -1
- package/bundled/locales/zh-CN/core/tech-debt-standards.md +71 -4
- package/bundled/locales/zh-CN/core/turn-completion-integrity.md +190 -0
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +27 -6
- package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +29 -68
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +172 -24
- package/bundled/locales/zh-CN/docs/USAGE-MODES-COMPARISON.md +1 -2
- package/bundled/locales/zh-CN/integrations/google-antigravity/{INSTRUCTIONS.md → AGENTS.md} +1 -1
- package/bundled/locales/zh-CN/integrations/google-antigravity/README.md +3 -3
- package/bundled/locales/zh-CN/skills/agents/README.md +1 -1
- package/bundled/locales/zh-CN/skills/atdd-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-CN/skills/bdd-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-CN/skills/brainstorm-assistant/SKILL.md +22 -12
- package/bundled/locales/zh-CN/skills/brainstorm-assistant/guide.md +12 -9
- package/bundled/locales/zh-CN/skills/code-review-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-CN/skills/commands/brainstorm.md +17 -13
- package/bundled/locales/zh-CN/skills/commands/config.md +0 -1
- package/bundled/locales/zh-CN/skills/commands/init.md +1 -2
- package/bundled/locales/zh-CN/skills/commit-standards/SKILL.md +2 -0
- package/bundled/locales/zh-CN/skills/contract-test-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-CN/skills/dev-methodology/SKILL.md +2 -0
- package/bundled/locales/zh-CN/skills/observability-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-CN/skills/project-structure-guide/SKILL.md +1 -0
- package/bundled/locales/zh-CN/skills/release-standards/SKILL.md +3 -0
- package/bundled/locales/zh-CN/skills/requirement-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-CN/skills/reverse-engineer/SKILL.md +3 -0
- package/bundled/locales/zh-CN/skills/reverse-engineer/tdd-analysis.md +13 -23
- package/bundled/locales/zh-CN/skills/runbook-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-CN/skills/slo-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-CN/skills/tdd-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-CN/skills/workflows/README.md +2 -11
- package/bundled/locales/zh-TW/CHANGELOG.md +65 -3
- package/bundled/locales/zh-TW/CLAUDE.md +1 -1
- package/bundled/locales/zh-TW/README.md +7 -7
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/acceptance-criteria-traceability.md +2 -0
- package/bundled/locales/zh-TW/core/adr-standards.md +26 -5
- package/bundled/locales/zh-TW/core/ai-instruction-standards.md +10 -8
- package/bundled/locales/zh-TW/core/code-review-checklist.md +2 -0
- package/bundled/locales/zh-TW/core/container-image-standards.md +2 -2
- package/bundled/locales/zh-TW/core/contract-testing-standards.md +2 -2
- package/bundled/locales/zh-TW/core/cross-flow-regression.md +8 -7
- package/bundled/locales/zh-TW/core/data-contract.md +2 -2
- package/bundled/locales/zh-TW/core/data-migration-testing.md +2 -2
- package/bundled/locales/zh-TW/core/data-pipeline.md +2 -2
- package/bundled/locales/zh-TW/core/deferred-item-exit.md +251 -0
- package/bundled/locales/zh-TW/core/documentation-writing-standards.md +228 -3
- package/bundled/locales/zh-TW/core/full-coverage-testing.md +15 -2
- package/bundled/locales/zh-TW/core/governance-layer.md +118 -5
- package/bundled/locales/zh-TW/core/iac-design-principles.md +2 -2
- package/bundled/locales/zh-TW/core/incident-response.md +2 -2
- package/bundled/locales/zh-TW/core/model-provenance.md +4 -2
- package/bundled/locales/zh-TW/core/pii-classification.md +42 -6
- package/bundled/locales/zh-TW/core/prd-standards.md +4 -2
- package/bundled/locales/zh-TW/core/product-metrics-standards.md +4 -2
- package/bundled/locales/zh-TW/core/release-readiness-gate.md +2 -2
- package/bundled/locales/zh-TW/core/resource-cost-boundary.md +2 -2
- package/bundled/locales/zh-TW/core/retrospective-standards.md +5 -3
- package/bundled/locales/zh-TW/core/reverse-engineering-standards.md +83 -5
- package/bundled/locales/zh-TW/core/runbook.md +2 -2
- package/bundled/locales/zh-TW/core/schema-evolution.md +2 -2
- package/bundled/locales/zh-TW/core/secret-management-standards.md +2 -2
- package/bundled/locales/zh-TW/core/slo-sli.md +2 -2
- package/bundled/locales/zh-TW/core/spec-driven-development.md +2 -0
- package/bundled/locales/zh-TW/core/tech-debt-standards.md +71 -4
- package/bundled/locales/zh-TW/core/turn-completion-integrity.md +190 -0
- package/bundled/locales/zh-TW/core/user-journey-testing.md +2 -2
- package/bundled/locales/zh-TW/core/user-story-mapping.md +2 -2
- package/bundled/locales/zh-TW/core/verification-oracle.md +2 -2
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +27 -6
- package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +29 -68
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +172 -24
- package/bundled/locales/zh-TW/docs/USAGE-MODES-COMPARISON.md +1 -2
- package/bundled/locales/zh-TW/integrations/google-antigravity/{INSTRUCTIONS.md → AGENTS.md} +1 -1
- package/bundled/locales/zh-TW/integrations/google-antigravity/README.md +3 -3
- package/bundled/locales/zh-TW/skills/adr-assistant/SKILL.md +1 -1
- package/bundled/locales/zh-TW/skills/agents/README.md +1 -1
- package/bundled/locales/zh-TW/skills/atdd-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-TW/skills/bdd-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-TW/skills/brainstorm-assistant/SKILL.md +22 -12
- package/bundled/locales/zh-TW/skills/brainstorm-assistant/guide.md +12 -9
- package/bundled/locales/zh-TW/skills/code-review-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-TW/skills/commands/brainstorm.md +17 -13
- package/bundled/locales/zh-TW/skills/commands/config.md +0 -1
- package/bundled/locales/zh-TW/skills/commands/init.md +1 -2
- package/bundled/locales/zh-TW/skills/commit-standards/SKILL.md +2 -0
- package/bundled/locales/zh-TW/skills/contract-test-assistant/SKILL.md +2 -1
- package/bundled/locales/zh-TW/skills/dev-methodology/SKILL.md +2 -0
- package/bundled/locales/zh-TW/skills/dev-workflow-guide/SKILL.md +1 -1
- package/bundled/locales/zh-TW/skills/knowledge-graph/guide.md +2 -2
- package/bundled/locales/zh-TW/skills/migration-assistant/SKILL.md +1 -1
- package/bundled/locales/zh-TW/skills/observability-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-TW/skills/project-discovery/SKILL.md +1 -0
- package/bundled/locales/zh-TW/skills/project-structure-guide/SKILL.md +1 -0
- package/bundled/locales/zh-TW/skills/release-standards/SKILL.md +3 -0
- package/bundled/locales/zh-TW/skills/requirement-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-TW/skills/reverse-engineer/SKILL.md +3 -0
- package/bundled/locales/zh-TW/skills/reverse-engineer/tdd-analysis.md +13 -23
- package/bundled/locales/zh-TW/skills/runbook-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-TW/skills/slo-assistant/SKILL.md +1 -0
- package/bundled/locales/zh-TW/skills/tdd-assistant/SKILL.md +2 -0
- package/bundled/locales/zh-TW/skills/workflows/README.md +2 -11
- package/bundled/skills/agents/README.md +1 -1
- package/bundled/skills/atdd-assistant/SKILL.md +2 -0
- package/bundled/skills/bdd-assistant/SKILL.md +2 -0
- package/bundled/skills/brainstorm-assistant/SKILL.md +31 -13
- package/bundled/skills/brainstorm-assistant/guide.md +9 -6
- package/bundled/skills/code-review-assistant/SKILL.md +1 -0
- package/bundled/skills/commands/brainstorm.md +12 -9
- package/bundled/skills/commands/config.md +0 -1
- package/bundled/skills/commands/init.md +2 -3
- package/bundled/skills/commit-standards/SKILL.md +2 -0
- package/bundled/skills/contract-test-assistant/SKILL.md +1 -0
- package/bundled/skills/dev-methodology/SKILL.md +4 -0
- package/bundled/skills/observability-assistant/SKILL.md +1 -0
- package/bundled/skills/project-discovery/SKILL.md +1 -0
- package/bundled/skills/project-structure-guide/SKILL.md +1 -0
- package/bundled/skills/release-standards/SKILL.md +3 -0
- package/bundled/skills/requirement-assistant/SKILL.md +2 -0
- package/bundled/skills/reverse-engineer/SKILL.md +3 -0
- package/bundled/skills/reverse-engineer/tdd-analysis.md +16 -23
- package/bundled/skills/runbook-assistant/SKILL.md +1 -0
- package/bundled/skills/slo-assistant/SKILL.md +1 -0
- package/bundled/skills/tdd-assistant/SKILL.md +2 -0
- package/bundled/skills/workflows/README.md +2 -11
- package/bundled/templates/.ai-context.yaml.template +194 -0
- package/bundled/templates/CLAUDE.md.template +145 -0
- package/bundled/templates/DESIGN.md +237 -0
- package/bundled/templates/SKILL-BRIEF-TEMPLATE.md +57 -0
- package/bundled/templates/SKILL-CANDIDATES.md +39 -0
- package/bundled/templates/gates/check-error-exit.mjs +309 -0
- package/bundled/templates/mcp-config.json +10 -0
- package/bundled/templates/methodology-template.yaml +209 -0
- package/bundled/templates/migration-template.md +408 -0
- package/bundled/templates/requirement-checklist.md +410 -0
- package/bundled/templates/requirement-document-template.md +591 -0
- package/bundled/templates/requirement-template.md +881 -0
- package/bundled/templates/reverse-spec-template.md +409 -0
- package/bundled/templates/test-case-template.md +74 -0
- package/bundled/templates/test-plan-template.md +74 -0
- package/package.json +9 -5
- package/src/commands/audit.js +82 -0
- package/src/commands/check.js +66 -10
- package/src/commands/init.js +161 -16
- package/src/commands/lint.js +96 -0
- package/src/commands/quickstart.js +16 -13
- package/src/commands/update.js +286 -14
- package/src/compilers/claude-code-compiler.js +4 -1
- package/src/config/ai-agent-paths.js +62 -17
- package/src/core/constants.js +42 -11
- package/src/core/manifest.js +201 -3
- package/src/core/paths.js +2 -2
- package/src/i18n/messages.js +9 -32
- package/src/installers/hooks-installer.js +167 -75
- package/src/installers/integration-installer.js +9 -5
- package/src/prompts/init.js +14 -14
- package/src/reconciler/actual-state-scanner.js +14 -3
- package/src/utils/detector.js +21 -1
- package/src/utils/effect-boundary.js +1093 -0
- package/src/utils/hasher.js +229 -9
- package/src/utils/hook-stats.js +1 -1
- package/src/utils/integration-generator.js +100 -4
- package/src/utils/reference-sync.js +4 -1
- package/src/utils/skills-installer.js +17 -3
- package/src/utils/spec-linter.js +35 -76
- package/src/utils/yaml-generator.js +51 -9
- package/standards-registry.json +31 -8
- package/src/commands/sync.js +0 -133
package/src/utils/hasher.js
CHANGED
|
@@ -1,22 +1,76 @@
|
|
|
1
1
|
import { createHash } from 'crypto';
|
|
2
|
-
import { readFileSync,
|
|
2
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
3
3
|
import { join, relative } from 'path';
|
|
4
4
|
import { UDS_MARKERS } from '../core/constants.js';
|
|
5
5
|
import { resolveIntegrationFile } from '../core/constants.js';
|
|
6
|
+
import { isProvenanceEstablished } from '../core/manifest.js';
|
|
7
|
+
|
|
8
|
+
// GitHub issue #155. `git config core.autocrlf true` (the common
|
|
9
|
+
// Windows default) rewrites LF to CRLF on checkout. The manifest's stored
|
|
10
|
+
// hashes are computed from the LF bytes git carries in the blob (that is what
|
|
11
|
+
// every non-Windows install reads), so a Windows working tree — content
|
|
12
|
+
// byte-for-byte what `git status` calls clean — hashed to something else
|
|
13
|
+
// entirely. Every `.standards/*` file, every skill/command file, and every
|
|
14
|
+
// CLAUDE.md/AGENTS.md UDS block came back "modified" although nothing had
|
|
15
|
+
// changed. Normalizing line endings before hashing (both when the manifest is
|
|
16
|
+
// written and when it is compared) makes the hash line-ending agnostic, so
|
|
17
|
+
// LF and CRLF checkouts of the same content agree.
|
|
18
|
+
//
|
|
19
|
+
// This intentionally makes "someone converted this file's line endings, text
|
|
20
|
+
// otherwise identical" invisible to `uds check`/`uds update`. That is the
|
|
21
|
+
// point, not a gap: line-ending convention is a checkout artifact, not
|
|
22
|
+
// content a standards library should track as a modification — it is the
|
|
23
|
+
// same normalization `git diff`/`git status` already apply before deciding a
|
|
24
|
+
// tracked file is dirty.
|
|
6
25
|
|
|
7
26
|
/**
|
|
8
|
-
*
|
|
27
|
+
* Heuristic binary-content detector, matching the approach git itself uses:
|
|
28
|
+
* a NUL byte anywhere in the first 8000 bytes marks the buffer as binary.
|
|
29
|
+
* Binary content must never be line-ending-normalized — flipping `\r\n` bytes
|
|
30
|
+
* inside an image or font would silently corrupt the hash rather than make it
|
|
31
|
+
* platform-agnostic, and could hide (or manufacture) a real difference.
|
|
32
|
+
* Every file `uds` currently manages under `.standards/`, skills, and
|
|
33
|
+
* commands is text (`.md`/`.yaml`/`.json`), but this function is the general
|
|
34
|
+
* per-file entry point (also used for arbitrary skill directory contents),
|
|
35
|
+
* so the check stays in place for whatever gets added later.
|
|
36
|
+
* @param {Buffer} buffer
|
|
37
|
+
* @returns {boolean} True if the buffer looks binary
|
|
38
|
+
*/
|
|
39
|
+
export function isBinaryContent(buffer) {
|
|
40
|
+
const sampleSize = Math.min(buffer.length, 8000);
|
|
41
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
42
|
+
if (buffer[i] === 0) return true;
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalize CRLF and lone-CR line endings to LF.
|
|
49
|
+
* @param {string} text
|
|
50
|
+
* @returns {string} Normalized text
|
|
51
|
+
*/
|
|
52
|
+
export function normalizeLineEndings(text) {
|
|
53
|
+
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Compute SHA-256 hash for a file, normalizing line endings first (unless the
|
|
58
|
+
* file is binary — see `isBinaryContent`). `size` is the byte length of the
|
|
59
|
+
* normalized content, not the raw on-disk size, so the two stay consistent
|
|
60
|
+
* with each other for the quick-reject check in `compareFileHash`.
|
|
9
61
|
* @param {string} filePath - Absolute file path
|
|
10
62
|
* @returns {Object|null} { hash, size } or null if file doesn't exist
|
|
11
63
|
*/
|
|
12
64
|
export function computeFileHash(filePath) {
|
|
13
65
|
try {
|
|
14
|
-
const
|
|
66
|
+
const raw = readFileSync(filePath);
|
|
67
|
+
const content = isBinaryContent(raw)
|
|
68
|
+
? raw
|
|
69
|
+
: Buffer.from(normalizeLineEndings(raw.toString('utf-8')), 'utf-8');
|
|
15
70
|
const hash = createHash('sha256').update(content).digest('hex');
|
|
16
|
-
const stats = statSync(filePath);
|
|
17
71
|
return {
|
|
18
72
|
hash: `sha256:${hash}`,
|
|
19
|
-
size:
|
|
73
|
+
size: content.length
|
|
20
74
|
};
|
|
21
75
|
} catch {
|
|
22
76
|
return null;
|
|
@@ -187,6 +241,15 @@ function scanDirectory(dirPath, basePath) {
|
|
|
187
241
|
|
|
188
242
|
/**
|
|
189
243
|
* Scan for untracked files in .standards/ and integration locations
|
|
244
|
+
*
|
|
245
|
+
* ⚠ "Untracked" here means only "absent from `manifest.fileHashes`". It is NOT
|
|
246
|
+
* an ownership predicate, and must not be used to decide what may be deleted —
|
|
247
|
+
* that is what it was used for, and it is why a user's hand-written file was
|
|
248
|
+
* removed without warning (issue #168). Use `planStandardsRemovals` /
|
|
249
|
+
* `classifyFileOwnership`, which separate "did UDS write this" from "is this
|
|
250
|
+
* still shipped". This function survives for reporting callers that genuinely
|
|
251
|
+
* want the hash-table question. (XSPEC-384 R1)
|
|
252
|
+
*
|
|
190
253
|
* @param {string} projectPath - Project root path
|
|
191
254
|
* @param {Object} manifest - Manifest object
|
|
192
255
|
* @returns {string[]} Array of relative paths to untracked files
|
|
@@ -218,7 +281,7 @@ export function scanForUntrackedFiles(projectPath, manifest) {
|
|
|
218
281
|
'.clinerules',
|
|
219
282
|
'.github/copilot-instructions.md',
|
|
220
283
|
'CLAUDE.md',
|
|
221
|
-
'
|
|
284
|
+
'.agents/AGENTS.md'
|
|
222
285
|
];
|
|
223
286
|
|
|
224
287
|
for (const intFile of knownIntegrations) {
|
|
@@ -231,6 +294,161 @@ export function scanForUntrackedFiles(projectPath, manifest) {
|
|
|
231
294
|
return untracked;
|
|
232
295
|
}
|
|
233
296
|
|
|
297
|
+
/**
|
|
298
|
+
* The three states a file under `.standards/` can be in. (XSPEC-384 R1)
|
|
299
|
+
*
|
|
300
|
+
* `scanForUntrackedFiles` answers a two-state question — in `fileHashes` or
|
|
301
|
+
* not — and the caller then treated "not" as "not ours, delete it". The world
|
|
302
|
+
* has a third state: files UDS did write whose manifest record was lost. The
|
|
303
|
+
* old predicate folded that into the same branch as a user's own file, and the
|
|
304
|
+
* branch deletes.
|
|
305
|
+
*/
|
|
306
|
+
export const FILE_OWNERSHIP = {
|
|
307
|
+
/** UDS wrote this file; we may remove it when it stops being shipped. */
|
|
308
|
+
UDS: 'uds-owned',
|
|
309
|
+
/** Provenance is complete and does not name this file: it is not ours. */
|
|
310
|
+
FOREIGN: 'foreign',
|
|
311
|
+
/** No provenance yet — we cannot tell, so we must not act destructively. */
|
|
312
|
+
UNKNOWN: 'unknown'
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Paths that are never candidates for anything, with the reason. */
|
|
316
|
+
const STRUCTURAL_EXCLUSIONS = new Map([
|
|
317
|
+
['.standards/manifest.json', 'the manifest itself']
|
|
318
|
+
]);
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Decide who owns one file under `.standards/`.
|
|
322
|
+
*
|
|
323
|
+
* @param {string} relPath - Path relative to project root (forward slashes)
|
|
324
|
+
* @param {Object} manifest - Manifest object
|
|
325
|
+
* @returns {string} One of FILE_OWNERSHIP
|
|
326
|
+
*/
|
|
327
|
+
export function classifyFileOwnership(relPath, manifest) {
|
|
328
|
+
const normalized = (relPath || '').replace(/\\/g, '/');
|
|
329
|
+
const provenanceFiles = manifest?.provenance?.files;
|
|
330
|
+
if (provenanceFiles && Object.prototype.hasOwnProperty.call(provenanceFiles, normalized)) {
|
|
331
|
+
return FILE_OWNERSHIP.UDS;
|
|
332
|
+
}
|
|
333
|
+
// Pre-provenance evidence. A path in `fileHashes` got there because UDS put
|
|
334
|
+
// it there, so it is still proof of authorship — just weaker proof, since it
|
|
335
|
+
// is also the table that loses entries. Reading it here (rather than only
|
|
336
|
+
// reading provenance) is what keeps the first upgrade from disowning every
|
|
337
|
+
// file installed before provenance existed.
|
|
338
|
+
if (manifest?.fileHashes && Object.prototype.hasOwnProperty.call(manifest.fileHashes, normalized)) {
|
|
339
|
+
return FILE_OWNERSHIP.UDS;
|
|
340
|
+
}
|
|
341
|
+
// No record either way. Whether that means "not ours" depends entirely on
|
|
342
|
+
// whether our records are complete yet.
|
|
343
|
+
return isProvenanceEstablished(manifest) ? FILE_OWNERSHIP.FOREIGN : FILE_OWNERSHIP.UNKNOWN;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Walk `.standards/` and classify everything in it.
|
|
348
|
+
*
|
|
349
|
+
* A walk, not a list of expected names: `.standards/` is an open set — UDS's
|
|
350
|
+
* own docs invite teams to add project-specific files to it — so any
|
|
351
|
+
* enumeration of "files we know about" is stale the moment someone adds one,
|
|
352
|
+
* and being absent from that enumeration is precisely what used to get a file
|
|
353
|
+
* deleted. (XSPEC-384 R1)
|
|
354
|
+
*
|
|
355
|
+
* @param {string} projectPath - Project root path
|
|
356
|
+
* @param {Object} manifest - Manifest object
|
|
357
|
+
* @returns {{scanned:number, excluded:Array, udsOwned:string[], foreign:string[], unknown:string[]}}
|
|
358
|
+
*/
|
|
359
|
+
export function classifyStandardsFiles(projectPath, manifest) {
|
|
360
|
+
const result = { scanned: 0, excluded: [], udsOwned: [], foreign: [], unknown: [] };
|
|
361
|
+
|
|
362
|
+
const standardsDir = join(projectPath, '.standards');
|
|
363
|
+
if (!existsSync(standardsDir)) return result;
|
|
364
|
+
|
|
365
|
+
for (const relPath of scanDirectory(standardsDir, projectPath)) {
|
|
366
|
+
const normalized = relPath.replace(/\\/g, '/');
|
|
367
|
+
result.scanned++;
|
|
368
|
+
|
|
369
|
+
const exclusionReason = STRUCTURAL_EXCLUSIONS.get(normalized);
|
|
370
|
+
if (exclusionReason) {
|
|
371
|
+
result.excluded.push({ path: normalized, reason: exclusionReason });
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
switch (classifyFileOwnership(normalized, manifest)) {
|
|
376
|
+
case FILE_OWNERSHIP.UDS:
|
|
377
|
+
result.udsOwned.push(normalized);
|
|
378
|
+
break;
|
|
379
|
+
case FILE_OWNERSHIP.FOREIGN:
|
|
380
|
+
result.foreign.push(normalized);
|
|
381
|
+
break;
|
|
382
|
+
default:
|
|
383
|
+
result.unknown.push(normalized);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Decide which files under `.standards/` `uds update` may delete.
|
|
392
|
+
*
|
|
393
|
+
* Deletion now needs two independent facts to line up, one per axis:
|
|
394
|
+
*
|
|
395
|
+
* 1. UDS wrote the file (ownership — provenance, or a legacy `fileHashes`
|
|
396
|
+
* entry as weaker evidence of the same thing)
|
|
397
|
+
* 2. UDS no longer ships it (currency — absence from `desiredFiles`, which
|
|
398
|
+
* the caller derives from the registry it just resolved)
|
|
399
|
+
*
|
|
400
|
+
* Every other combination is kept, and says why it was kept. The old rule
|
|
401
|
+
* collapsed both axes onto `fileHashes` membership, so it deleted files it had
|
|
402
|
+
* never written (#168) while retaining files it no longer shipped (#165).
|
|
403
|
+
*
|
|
404
|
+
* Nothing is removed when `desiredFiles` is null: a caller that cannot say what
|
|
405
|
+
* should exist has not established fact 2, and "I don't know" must not resolve
|
|
406
|
+
* to the destructive branch — that was the original defect.
|
|
407
|
+
*
|
|
408
|
+
* @param {string} projectPath - Project root path
|
|
409
|
+
* @param {Object} manifest - Manifest object
|
|
410
|
+
* @param {Set<string>|null} desiredFiles - Files the current registry says should exist
|
|
411
|
+
* @returns {{scanned:number, excluded:Array, remove:Array, keep:Array, census:Object}}
|
|
412
|
+
*/
|
|
413
|
+
export function planStandardsRemovals(projectPath, manifest, desiredFiles = null) {
|
|
414
|
+
const census = classifyStandardsFiles(projectPath, manifest);
|
|
415
|
+
const remove = [];
|
|
416
|
+
const keep = [];
|
|
417
|
+
|
|
418
|
+
const desiredKnown = desiredFiles instanceof Set;
|
|
419
|
+
|
|
420
|
+
for (const path of census.udsOwned) {
|
|
421
|
+
if (!desiredKnown) {
|
|
422
|
+
keep.push({ path, reason: 'UDS-owned, but this run could not determine what is still shipped' });
|
|
423
|
+
} else if (desiredFiles.has(path)) {
|
|
424
|
+
keep.push({ path, reason: 'UDS-owned and still shipped' });
|
|
425
|
+
} else {
|
|
426
|
+
remove.push({ path, reason: 'UDS-owned and no longer shipped by the registry' });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for (const path of census.foreign) {
|
|
431
|
+
keep.push({ path, reason: 'not written by UDS' });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
for (const path of census.unknown) {
|
|
435
|
+
keep.push({ path, reason: 'ownership unknown (manifest predates provenance)' });
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
scanned: census.scanned,
|
|
440
|
+
excluded: census.excluded,
|
|
441
|
+
remove,
|
|
442
|
+
keep,
|
|
443
|
+
census: {
|
|
444
|
+
udsOwned: census.udsOwned.length,
|
|
445
|
+
foreign: census.foreign.length,
|
|
446
|
+
unknown: census.unknown.length,
|
|
447
|
+
desired: desiredKnown ? desiredFiles.size : null
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
234
452
|
/**
|
|
235
453
|
* Detect file format based on file path
|
|
236
454
|
* @param {string} filePath - File path
|
|
@@ -276,7 +494,10 @@ function extractBlockContent(content, format) {
|
|
|
276
494
|
*/
|
|
277
495
|
export function computeIntegrationBlockHash(filePath) {
|
|
278
496
|
try {
|
|
279
|
-
|
|
497
|
+
// CLAUDE.md / AGENTS.md are always text — no binary check needed here,
|
|
498
|
+
// unlike `computeFileHash`.
|
|
499
|
+
const rawContent = readFileSync(filePath, 'utf-8');
|
|
500
|
+
const content = normalizeLineEndings(rawContent);
|
|
280
501
|
const format = detectFormat(filePath);
|
|
281
502
|
const { blockContent } = extractBlockContent(content, format);
|
|
282
503
|
|
|
@@ -287,13 +508,12 @@ export function computeIntegrationBlockHash(filePath) {
|
|
|
287
508
|
|
|
288
509
|
const blockHash = createHash('sha256').update(blockContent).digest('hex');
|
|
289
510
|
const fullHash = createHash('sha256').update(content).digest('hex');
|
|
290
|
-
const stats = statSync(filePath);
|
|
291
511
|
|
|
292
512
|
return {
|
|
293
513
|
blockHash: `sha256:${blockHash}`,
|
|
294
514
|
blockSize: Buffer.byteLength(blockContent, 'utf-8'),
|
|
295
515
|
fullHash: `sha256:${fullHash}`,
|
|
296
|
-
fullSize:
|
|
516
|
+
fullSize: Buffer.byteLength(content, 'utf-8')
|
|
297
517
|
};
|
|
298
518
|
} catch {
|
|
299
519
|
return null;
|
package/src/utils/hook-stats.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook Statistics - Context-aware loading learning loop
|
|
3
3
|
*
|
|
4
|
-
* Records and analyzes trigger statistics from inject-standards.
|
|
4
|
+
* Records and analyzes trigger statistics from inject-standards.mjs hook.
|
|
5
5
|
* Privacy: never records full prompt content or file paths.
|
|
6
6
|
*
|
|
7
7
|
* @module utils/hook-stats
|
|
@@ -32,7 +32,10 @@ export function resolveContentModeForTool(tool, userContentMode) {
|
|
|
32
32
|
? { contentMode: 'minimal', level: 3 }
|
|
33
33
|
: { contentMode: 'index', level: 2 };
|
|
34
34
|
case 'partial':
|
|
35
|
-
|
|
35
|
+
// Was 'full'. Every partial-tier tool was being auto-assigned a mode
|
|
36
|
+
// that generated exactly the same bytes as 'index', so this is a rename
|
|
37
|
+
// of what these tools already got, not a change to it. (XSPEC-357 R7)
|
|
38
|
+
return { contentMode: 'index', level: 1 };
|
|
36
39
|
case 'preview':
|
|
37
40
|
return { contentMode: 'index', level: 2 };
|
|
38
41
|
case 'minimal':
|
|
@@ -70,6 +73,31 @@ function getToolFileName(tool) {
|
|
|
70
73
|
if (KNOWN_TOOL_FILES.has(tool) || /\.(md|yaml|yml|json)$/i.test(tool)) {
|
|
71
74
|
return tool;
|
|
72
75
|
}
|
|
76
|
+
|
|
77
|
+
// 🔴 A tool THIS system already knows, missing from THIS table, is an internal
|
|
78
|
+
// inconsistency — never a future tool. It must fail rather than get a generated
|
|
79
|
+
// filename.
|
|
80
|
+
//
|
|
81
|
+
// Measured 2026-09-08: `roo-code` is the key used by `ai-agent-paths.js`,
|
|
82
|
+
// `REGISTRY.json`, `agent-adapter.js` and `agents-installer.js`, while
|
|
83
|
+
// `SUPPORTED_AI_TOOLS` said `roo`. Every lookup here missed and fell through, so a
|
|
84
|
+
// real `uds init` in a Roo Code repo wrote its instructions to `roo-code.md` — a
|
|
85
|
+
// filename Roo Code's docs never mention. Nothing failed. Nothing warned. Same
|
|
86
|
+
// class as `.codex/skills/`, which this repo's own comments call "a directory UDS
|
|
87
|
+
// invented"; the line below was a *rule* for inventing them.
|
|
88
|
+
//
|
|
89
|
+
// ⚠️ The fallback itself is KEPT, because it is a deliberate design decision, not
|
|
90
|
+
// an accident: three tests assert it as "forward compatibility with future tools".
|
|
91
|
+
// Narrowing beats reversing — a genuinely unknown name still gets `<name>.md`; only
|
|
92
|
+
// the self-contradiction fails.
|
|
93
|
+
if (getAgentConfig(normalizedName)) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`getToolFileName: "${tool}" is a known agent in ai-agent-paths.js but is missing ` +
|
|
96
|
+
'from SUPPORTED_AI_TOOLS in cli/src/core/constants.js. Add it with the file that ' +
|
|
97
|
+
`tool actually reads. Refusing to invent "${tool}.md" — that is how a Roo Code ` +
|
|
98
|
+
'repo got its instructions written to a file Roo Code never reads.'
|
|
99
|
+
);
|
|
100
|
+
}
|
|
73
101
|
return `${tool}.md`;
|
|
74
102
|
}
|
|
75
103
|
|
|
@@ -2398,6 +2426,52 @@ All responses should be in **Traditional Chinese (繁體中文)**, with technica
|
|
|
2398
2426
|
}
|
|
2399
2427
|
};
|
|
2400
2428
|
|
|
2429
|
+
/**
|
|
2430
|
+
* Generate the index disclosure that opens every UDS-managed block.
|
|
2431
|
+
*
|
|
2432
|
+
* XSPEC-357 R7 — the block an adopter receives is an index. In no content mode
|
|
2433
|
+
* does it carry rule bodies, and inlining them is not an option: 143 `.ai.yaml`
|
|
2434
|
+
* files come to roughly 248k tokens. What can be fixed is the block pretending
|
|
2435
|
+
* otherwise. A file that lists 72 standard paths under a heading reading
|
|
2436
|
+
* "Standards Compliance Instructions" reads, to an agent, like the compliance
|
|
2437
|
+
* instructions — and Codex was measured on 2026-07-23 doing exactly that:
|
|
2438
|
+
* it enumerated the standards and opened none of them.
|
|
2439
|
+
*
|
|
2440
|
+
* This wording was added to the universal `AGENTS.md` summary on 2026-08-18
|
|
2441
|
+
* (`generateAgentsMdSummary`). That fixed one of the two producers. The other
|
|
2442
|
+
* one is this — and the split is worse than it sounds, because the two are
|
|
2443
|
+
* mutually exclusive for the same filename: selecting codex or opencode turns
|
|
2444
|
+
* the universal summary OFF and routes `AGENTS.md` through here instead. The
|
|
2445
|
+
* disclosure written for Codex was therefore in the only file a Codex adopter
|
|
2446
|
+
* never receives. Measured 2026-08-20 before this change: 8 distinct adopter
|
|
2447
|
+
* files across 3 content modes, 0 carrying the disclosure.
|
|
2448
|
+
*
|
|
2449
|
+
* @param {string} format - Output format: 'markdown' or 'plaintext'
|
|
2450
|
+
* @param {string} language - Language: 'en', 'zh-tw', 'zh-cn' or 'bilingual'
|
|
2451
|
+
* @returns {string} Disclosure paragraph, already format-adjusted
|
|
2452
|
+
*/
|
|
2453
|
+
export function generateIndexDisclosure(format, language = 'en') {
|
|
2454
|
+
const lines = language === 'en'
|
|
2455
|
+
? [
|
|
2456
|
+
'**This block is an index, not the standards.** The rules are NOT reproduced here.',
|
|
2457
|
+
'Before acting on anything below, open the relevant file under `.standards/` and',
|
|
2458
|
+
'follow its contents. Working from this block alone means working without the',
|
|
2459
|
+
'standards.'
|
|
2460
|
+
]
|
|
2461
|
+
: [
|
|
2462
|
+
'**這個區塊是索引,不是標準本文。** 規則並未複製於此。',
|
|
2463
|
+
'在依照下方任何一項行動之前,請打開 `.standards/` 底下對應的檔案並遵守其內容。',
|
|
2464
|
+
'只憑這個區塊工作,等同於沒有採用標準。'
|
|
2465
|
+
];
|
|
2466
|
+
|
|
2467
|
+
if (format === 'markdown') {
|
|
2468
|
+
return lines.map((l) => `> ${l}`).join('\n');
|
|
2469
|
+
}
|
|
2470
|
+
// Plaintext targets (.cursorrules / .clinerules / .windsurfrules) render
|
|
2471
|
+
// neither blockquotes nor backticks, so both are stripped rather than shown.
|
|
2472
|
+
return lines.map((l) => l.replace(/\*\*/g, '').replace(/`/g, '')).join('\n');
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2401
2475
|
/**
|
|
2402
2476
|
* Generate minimal standards reference for minimal content mode
|
|
2403
2477
|
* @param {string[]} installedStandards - List of installed standard file paths
|
|
@@ -2733,6 +2807,10 @@ export function generateIntegrationContent(config) {
|
|
|
2733
2807
|
}
|
|
2734
2808
|
|
|
2735
2809
|
if (installedStandards.length > 0) {
|
|
2810
|
+
// XSPEC-357 R7 — every mode, every tool. Placed first because it is a
|
|
2811
|
+
// precondition for reading the rest, not a footnote to it.
|
|
2812
|
+
standardsContent = generateIndexDisclosure(format, language) + '\n\n' + standardsContent;
|
|
2813
|
+
|
|
2736
2814
|
if (contentMode === 'minimal') {
|
|
2737
2815
|
// Minimal mode: simple reference list
|
|
2738
2816
|
standardsContent += generateMinimalStandardsReference(
|
|
@@ -3181,8 +3259,8 @@ export function parseStandardsIndexCount(content) {
|
|
|
3181
3259
|
export function wrapWithMarkers(content, format) {
|
|
3182
3260
|
const markers = UDS_MARKERS[format] || UDS_MARKERS.markdown;
|
|
3183
3261
|
const warning = format === 'plaintext'
|
|
3184
|
-
? '# WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds
|
|
3185
|
-
: '<!-- WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds
|
|
3262
|
+
? '# WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds init\' or \'npx uds update\' to modify.'
|
|
3263
|
+
: '<!-- WARNING: This block is managed by UDS (universal-dev-standards). DO NOT manually edit. Use \'npx uds init\' or \'npx uds update\' to modify. -->';
|
|
3186
3264
|
// 冪等:warning 位於 markers **內部**,而 extractMarkedContent 取出的內容也含它,
|
|
3187
3265
|
// 於是重新包裝會疊出第二份(dev-platform CLAUDE.md 實測 178/179 兩行完全相同)。
|
|
3188
3266
|
// 這裡先剝掉內容開頭既有的 warning,不論上游哪條路徑造成都能修掉。
|
|
@@ -3362,7 +3440,25 @@ export function generateAgentsMdSummary(config = {}) {
|
|
|
3362
3440
|
lines.push('# AGENTS.md');
|
|
3363
3441
|
lines.push('');
|
|
3364
3442
|
lines.push('> Auto-generated by [Universal Dev Standards (UDS)](https://github.com/AsiaOstrich/universal-dev-standards).');
|
|
3365
|
-
lines.push('>
|
|
3443
|
+
lines.push('>');
|
|
3444
|
+
// Say it as an instruction, and say what this file is NOT.
|
|
3445
|
+
//
|
|
3446
|
+
// The previous line — "Full standards available in the `.standards/` directory" — was a
|
|
3447
|
+
// description, and a description asks for nothing. Measured 2026-07-23: Codex read this
|
|
3448
|
+
// file, listed the 65 standards it indexes, and opened none of them, so the rules had the
|
|
3449
|
+
// same effect as not installing UDS at all. Measured again 2026-08-18 on a fresh
|
|
3450
|
+
// `uds init -y`: the generated file is 5,667 bytes containing 69 filename references and
|
|
3451
|
+
// **zero rule statements**. That is not a bug in the generator — 143 `.ai.yaml` files come
|
|
3452
|
+
// to roughly 248k tokens, so inlining them is not possible — which is exactly why the file
|
|
3453
|
+
// has to be explicit that it is an index and that the rules are elsewhere.
|
|
3454
|
+
//
|
|
3455
|
+
// This does not prove the rules get read; only XSPEC-357's P7 probe can measure that, and
|
|
3456
|
+
// it is not built yet. It removes the one thing that was certainly wrong: a file that read
|
|
3457
|
+
// as though it carried the standards when it carried their filenames. (XSPEC-357 R7)
|
|
3458
|
+
lines.push('> **This file is an index, not the standards.** The rules are NOT reproduced here.');
|
|
3459
|
+
lines.push('> Before acting on anything below, open the relevant file under `.standards/`');
|
|
3460
|
+
lines.push('> and follow its contents. Working from this summary alone means working');
|
|
3461
|
+
lines.push('> without the standards.');
|
|
3366
3462
|
lines.push('');
|
|
3367
3463
|
|
|
3368
3464
|
// Build & Test Commands (auto-detect project type)
|
|
@@ -296,7 +296,10 @@ export function getToolFromPath(integrationPath) {
|
|
|
296
296
|
'.windsurfrules': 'windsurf',
|
|
297
297
|
'.clinerules': 'cline',
|
|
298
298
|
'.github/copilot-instructions.md': 'copilot',
|
|
299
|
-
|
|
299
|
+
// Antigravity never read INSTRUCTIONS.md.
|
|
300
|
+
// Measured 2026-09-08 with two positive controls in the same run: tokens planted in `AGENTS.md` and `.agents/AGENTS.md` both came back with correct attribution; the one in INSTRUCTIONS.md did not.
|
|
301
|
+
// `.agents/AGENTS.md` is used rather than the repo root so it does not collide with Codex/OpenCode, which both target root AGENTS.md.
|
|
302
|
+
'.agents/AGENTS.md': 'antigravity',
|
|
300
303
|
'CLAUDE.md': 'claude-code',
|
|
301
304
|
'.standards/CLAUDE.md': 'claude-code',
|
|
302
305
|
'AGENTS.md': 'codex'
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
getCommandsSupportedAgents,
|
|
20
20
|
getCommandFileExtension
|
|
21
21
|
} from '../config/ai-agent-paths.js';
|
|
22
|
-
import { computeDirectoryHashes, computeFileHash } from './hasher.js';
|
|
22
|
+
import { computeDirectoryHashes, computeFileHash, normalizeLineEndings } from './hasher.js';
|
|
23
23
|
import { isLocalizedLocale } from './locale.js';
|
|
24
24
|
import { getSkillsSourceDir } from './skills-source.js';
|
|
25
25
|
|
|
@@ -504,6 +504,16 @@ export function resolveSkillFiles(skillName, locale = 'en') {
|
|
|
504
504
|
* are different installs, and a hash over contents alone would call a rename
|
|
505
505
|
* "unchanged".
|
|
506
506
|
*
|
|
507
|
+
* Content is line-ending normalized before hashing (GitHub issue #155): the
|
|
508
|
+
* counterpart on the actual-state side, `hashInstalledSkillDir` in
|
|
509
|
+
* `reconciler/actual-state-scanner.js`, hashes files that were checked out
|
|
510
|
+
* into the adopter's own repo and so may have been rewritten to CRLF by
|
|
511
|
+
* `core.autocrlf=true` on Windows. Both sides must apply the same
|
|
512
|
+
* normalization or every skill would report as changed on Windows, same as
|
|
513
|
+
* the `.standards/*` hashes this issue was filed about — the two functions
|
|
514
|
+
* only stay comparable by construction if they agree on this too, which is
|
|
515
|
+
* why the paired tests in `skill-content-hash.test.js` exist.
|
|
516
|
+
*
|
|
507
517
|
* @param {Array<{name: string, content: string}>} files
|
|
508
518
|
* @returns {string|null} `sha256:<hex>`, or null for an empty resolution
|
|
509
519
|
*/
|
|
@@ -513,7 +523,7 @@ export function computeSkillContentHash(files) {
|
|
|
513
523
|
for (const f of files) {
|
|
514
524
|
h.update(f.name);
|
|
515
525
|
h.update('\0');
|
|
516
|
-
h.update(f.content);
|
|
526
|
+
h.update(normalizeLineEndings(f.content));
|
|
517
527
|
h.update('\0');
|
|
518
528
|
}
|
|
519
529
|
return `sha256:${h.digest('hex')}`;
|
|
@@ -779,13 +789,17 @@ export function resolveCommandContent(cmdName, agent, locale = 'en') {
|
|
|
779
789
|
*
|
|
780
790
|
* Same `sha256:` shape as `computeSkillContentHash` so the two sides of the
|
|
781
791
|
* diff can be compared without caring which category an entry came from.
|
|
792
|
+
* Line-ending normalized before hashing for the same reason as
|
|
793
|
+
* `computeSkillContentHash` (GitHub issue #155) — its actual-state
|
|
794
|
+
* counterpart in `reconciler/actual-state-scanner.js` hashes an installed
|
|
795
|
+
* command file that may have been checked out as CRLF on Windows.
|
|
782
796
|
*
|
|
783
797
|
* @param {string|null} content
|
|
784
798
|
* @returns {string|null}
|
|
785
799
|
*/
|
|
786
800
|
export function computeCommandContentHash(content) {
|
|
787
801
|
if (content === null || content === undefined) return null;
|
|
788
|
-
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
802
|
+
return `sha256:${createHash('sha256').update(normalizeLineEndings(content)).digest('hex')}`;
|
|
789
803
|
}
|
|
790
804
|
|
|
791
805
|
function installSingleCommand(cmdName, targetDir, agent, locale = 'en') {
|
package/src/utils/spec-linter.js
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
* Spec Linter — Stateless analysis functions for spec quality checks
|
|
3
3
|
* @module utils/spec-linter
|
|
4
4
|
* @see specs/superspec-borrowing-phase1-2-spec.md (AC-11, AC-12, AC-13)
|
|
5
|
+
*
|
|
6
|
+
* XSPEC-383 R5 (Option E): `checkACCoverage` / `collectTestFiles` / `scanDir`
|
|
7
|
+
* were removed here. They shipped 2026-04-07, had passing unit tests, and were
|
|
8
|
+
* never wired to any CLI command — `uds lint` did not exist. 2026-08-19,
|
|
9
|
+
* registering `uds lint` and running it against VibeOps's 93 real specs
|
|
10
|
+
* surfaced that the removed check would have reported 0/98 ACs covered on
|
|
11
|
+
* every one of them, for two independent reasons: it derived AC identifiers
|
|
12
|
+
* positionally (`AC-1`, `AC-2`, …) instead of reading the ones a spec
|
|
13
|
+
* declares, and it hardcoded the `@AC-N` tag convention from this repo's own
|
|
14
|
+
* `skills/ac-coverage`, while VibeOps's tests tag coverage as `AC-045-001`
|
|
15
|
+
* without an `@` prefix. Neither project is "wrong" — they never agreed on a
|
|
16
|
+
* convention — but a linter that always reports zero regardless of actual
|
|
17
|
+
* coverage is worse than no linter: it looks like a working gate. Redoing AC
|
|
18
|
+
* coverage requires deciding how identifiers are read and how conventions are
|
|
19
|
+
* negotiated across adopters; that is a new design, not a patch, and is out
|
|
20
|
+
* of scope for R5.
|
|
5
21
|
*/
|
|
6
22
|
|
|
7
23
|
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
@@ -9,35 +25,6 @@ import { join, basename } from 'path';
|
|
|
9
25
|
import { StandardValidator } from './standard-validator.js';
|
|
10
26
|
import { MicroSpec } from '../vibe/micro-spec.js';
|
|
11
27
|
|
|
12
|
-
/**
|
|
13
|
-
* Check if a spec's ACs are referenced in test files
|
|
14
|
-
* @param {string} specId - e.g. "SPEC-001"
|
|
15
|
-
* @param {string[]} acIds - e.g. ["AC-1", "AC-2", "AC-3"]
|
|
16
|
-
* @param {string} projectPath - Project root directory
|
|
17
|
-
* @returns {{ covered: string[], orphans: string[], coverage: number }}
|
|
18
|
-
*/
|
|
19
|
-
export function checkACCoverage(specId, acIds, projectPath) {
|
|
20
|
-
const covered = [];
|
|
21
|
-
const orphans = [];
|
|
22
|
-
|
|
23
|
-
// Collect all test file contents
|
|
24
|
-
const testContent = collectTestFiles(projectPath);
|
|
25
|
-
|
|
26
|
-
for (const acId of acIds) {
|
|
27
|
-
// Search for @AC-N pattern in test files
|
|
28
|
-
const pattern = new RegExp(`@${acId}\\b`);
|
|
29
|
-
if (testContent.some(content => pattern.test(content))) {
|
|
30
|
-
covered.push(acId);
|
|
31
|
-
} else {
|
|
32
|
-
orphans.push(acId);
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const coverage = acIds.length > 0 ? covered.length / acIds.length : 0;
|
|
37
|
-
|
|
38
|
-
return { covered, orphans, coverage };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
28
|
/**
|
|
42
29
|
* Validate depends_on references exist
|
|
43
30
|
* @param {Object[]} specs - Array of specs with { id, dependsOn }
|
|
@@ -75,14 +62,22 @@ export function checkSpecSize(specFilePath, options = {}) {
|
|
|
75
62
|
}
|
|
76
63
|
|
|
77
64
|
/**
|
|
78
|
-
* Run all lint checks on specs in a project
|
|
65
|
+
* Run all lint checks (dependency validity + size) on specs in a project.
|
|
66
|
+
*
|
|
67
|
+
* `specsDirExists` is a three-state signal, not decoration: "no specs
|
|
68
|
+
* directory" and "specs directory with zero problems" must not collapse into
|
|
69
|
+
* the same `{ pass: 0, warn: 0, fail: 0 }` shape, or a caller cannot tell
|
|
70
|
+
* "nothing was checked" from "everything is fine" (XSPEC-383 R4/R5's own
|
|
71
|
+
* standing rule for this repo's gates — see check-module-reachability.mjs and
|
|
72
|
+
* check-command-existence.mjs).
|
|
73
|
+
*
|
|
79
74
|
* @param {string} projectPath - Project root directory
|
|
80
|
-
* @returns {{ results: Object[], summary: { pass: number, warn: number, fail: number } }}
|
|
75
|
+
* @returns {{ results: Object[], summary: { pass: number, warn: number, fail: number }, specsDir: string, specsDirExists: boolean }}
|
|
81
76
|
*/
|
|
82
77
|
export function lintAll(projectPath) {
|
|
83
78
|
const specsDir = join(projectPath, 'specs');
|
|
84
79
|
if (!existsSync(specsDir)) {
|
|
85
|
-
return { results: [], summary: { pass: 0, warn: 0, fail: 0 } };
|
|
80
|
+
return { results: [], summary: { pass: 0, warn: 0, fail: 0 }, specsDir: 'specs', specsDirExists: false };
|
|
86
81
|
}
|
|
87
82
|
|
|
88
83
|
// Load all specs
|
|
@@ -102,10 +97,6 @@ export function lintAll(projectPath) {
|
|
|
102
97
|
const summary = { pass: 0, warn: 0, fail: 0 };
|
|
103
98
|
|
|
104
99
|
for (const spec of allSpecs) {
|
|
105
|
-
// AC coverage
|
|
106
|
-
const acIds = (spec.acceptance || []).map((_, i) => `AC-${i + 1}`);
|
|
107
|
-
const acCoverage = checkACCoverage(spec.id, acIds, projectPath);
|
|
108
|
-
|
|
109
100
|
// Dependencies for this spec
|
|
110
101
|
const specBroken = depResults.broken.filter(b => b.spec === spec.id);
|
|
111
102
|
const specValid = depResults.valid.filter(v => v.spec === spec.id);
|
|
@@ -116,22 +107,18 @@ export function lintAll(projectPath) {
|
|
|
116
107
|
const size = checkSpecSize(specPath);
|
|
117
108
|
|
|
118
109
|
// Determine worst status
|
|
119
|
-
let
|
|
120
|
-
if (specBroken.length > 0 || size.status === 'fail'
|
|
121
|
-
|
|
122
|
-
} else if (size.status === 'warn'
|
|
123
|
-
|
|
110
|
+
let status = 'pass';
|
|
111
|
+
if (specBroken.length > 0 || size.status === 'fail') {
|
|
112
|
+
status = 'fail';
|
|
113
|
+
} else if (size.status === 'warn') {
|
|
114
|
+
status = 'warn';
|
|
124
115
|
}
|
|
125
116
|
|
|
126
|
-
summary[
|
|
117
|
+
summary[status]++;
|
|
127
118
|
|
|
128
119
|
results.push({
|
|
129
120
|
spec: spec.id,
|
|
130
|
-
|
|
131
|
-
covered: acCoverage.covered,
|
|
132
|
-
orphans: acCoverage.orphans,
|
|
133
|
-
coverage: acCoverage.coverage,
|
|
134
|
-
},
|
|
121
|
+
status,
|
|
135
122
|
deps,
|
|
136
123
|
size: {
|
|
137
124
|
effectiveLines: size.effectiveLines,
|
|
@@ -140,33 +127,5 @@ export function lintAll(projectPath) {
|
|
|
140
127
|
});
|
|
141
128
|
}
|
|
142
129
|
|
|
143
|
-
return { results, summary };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ─── Internal helpers ───
|
|
147
|
-
|
|
148
|
-
function collectTestFiles(projectPath) {
|
|
149
|
-
const contents = [];
|
|
150
|
-
const testDirs = ['tests', 'test', '__tests__', 'cli/tests'];
|
|
151
|
-
|
|
152
|
-
for (const dir of testDirs) {
|
|
153
|
-
const fullDir = join(projectPath, dir);
|
|
154
|
-
if (existsSync(fullDir)) {
|
|
155
|
-
scanDir(fullDir, contents);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return contents;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function scanDir(dir, contents) {
|
|
163
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
164
|
-
for (const entry of entries) {
|
|
165
|
-
const fullPath = join(dir, entry.name);
|
|
166
|
-
if (entry.isDirectory()) {
|
|
167
|
-
scanDir(fullPath, contents);
|
|
168
|
-
} else if (entry.name.match(/\.(test|spec)\.(js|ts|mjs|cjs)$/)) {
|
|
169
|
-
contents.push(readFileSync(fullPath, 'utf-8'));
|
|
170
|
-
}
|
|
171
|
-
}
|
|
130
|
+
return { results, summary, specsDir: 'specs', specsDirExists: true };
|
|
172
131
|
}
|