dsh-archify-projects 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +204 -2
- package/cordis.patch.yml +99 -0
- package/dist/client.js +2261 -0
- package/dist/client.js.map +1 -0
- package/docs/CONTAINER.md +197 -0
- package/docs/DESIGN.md +905 -0
- package/docs/HANDOVER.md +391 -0
- package/docs/PUBLISH.md +107 -0
- package/icon.svg +12 -0
- package/lib/host/archify-skill.js +116 -0
- package/lib/host/archify-skill.js.map +1 -0
- package/lib/host/config.js +95 -0
- package/lib/host/config.js.map +1 -0
- package/lib/host/context.js +28 -0
- package/lib/host/context.js.map +1 -0
- package/lib/host/git.js +185 -0
- package/lib/host/git.js.map +1 -0
- package/lib/host/http.js +184 -0
- package/lib/host/http.js.map +1 -0
- package/lib/host/index.js +90 -0
- package/lib/host/index.js.map +1 -0
- package/lib/host/policy.js +124 -0
- package/lib/host/policy.js.map +1 -0
- package/lib/host/routes.js +667 -0
- package/lib/host/routes.js.map +1 -0
- package/lib/host/runner.js +913 -0
- package/lib/host/runner.js.map +1 -0
- package/lib/host/store.js +370 -0
- package/lib/host/store.js.map +1 -0
- package/lib/host/workspace.js +65 -0
- package/lib/host/workspace.js.map +1 -0
- package/lib/shared/format.js +138 -0
- package/lib/shared/format.js.map +1 -0
- package/lib/shared/prompt.js +257 -0
- package/lib/shared/prompt.js.map +1 -0
- package/lib/shared/protocol.js +31 -0
- package/lib/shared/protocol.js.map +1 -0
- package/lib/shared/types.js +173 -0
- package/lib/shared/types.js.map +1 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +98 -4
- package/skills/archify/LICENSE +22 -0
- package/skills/archify/SKILL.md +109 -0
- package/skills/archify/THIRD_PARTY_NOTICES.md +69 -0
- package/skills/archify/assets/JetBrainsMono-OFL.txt +93 -0
- package/skills/archify/assets/template.html +13709 -0
- package/skills/archify/bin/archify.mjs +6936 -0
- package/skills/archify/bin/delivery-update.mjs +91 -0
- package/skills/archify/bin/finalize.mjs +895 -0
- package/skills/archify/bin/open-artifact.mjs +146 -0
- package/skills/archify/bin/preview.mjs +969 -0
- package/skills/archify/bin/recover-output.mjs +41 -0
- package/skills/archify/bin/visual-check.mjs +2667 -0
- package/skills/archify/brand-marks/README.md +31 -0
- package/skills/archify/brand-marks/catalog.json +131 -0
- package/skills/archify/delta/architecture-delta.mjs +1313 -0
- package/skills/archify/examples/agent-run.lifecycle.json +55 -0
- package/skills/archify/examples/agent-tool-call.workflow.json +74 -0
- package/skills/archify/examples/async-job-roundtrip.sequence.json +56 -0
- package/skills/archify/examples/brand-aware-delivery.architecture.json +34 -0
- package/skills/archify/examples/cache-miss-request.sequence.json +77 -0
- package/skills/archify/examples/checkout-platform.base.architecture.json +32 -0
- package/skills/archify/examples/checkout-platform.head.architecture.json +32 -0
- package/skills/archify/examples/dataflow-product-analytics.html +13820 -0
- package/skills/archify/examples/deployment-release.lifecycle.json +48 -0
- package/skills/archify/examples/event-stream.dataflow.json +52 -0
- package/skills/archify/examples/holiday-planning.workflow.json +143 -0
- package/skills/archify/examples/incident-response.workflow.json +59 -0
- package/skills/archify/examples/lifecycle-agent-run.html +13765 -0
- package/skills/archify/examples/locales/de.json +425 -0
- package/skills/archify/examples/locales/es.json +440 -0
- package/skills/archify/examples/locales/fr.json +425 -0
- package/skills/archify/examples/locales/fr.partial.json +22 -0
- package/skills/archify/examples/locales/it.json +425 -0
- package/skills/archify/examples/locales/ja.json +425 -0
- package/skills/archify/examples/locales/ko.json +440 -0
- package/skills/archify/examples/locales/pt.json +425 -0
- package/skills/archify/examples/locales/ru.json +425 -0
- package/skills/archify/examples/product-analytics.dataflow.json +71 -0
- package/skills/archify/examples/production-deployment.architecture.json +51 -0
- package/skills/archify/examples/release-delivery.workflow.json +57 -0
- package/skills/archify/examples/sequence-cache-miss-request.html +13835 -0
- package/skills/archify/examples/web-app-rendered.html +13808 -0
- package/skills/archify/examples/web-app.architecture.json +41 -0
- package/skills/archify/examples/workflow-agent-tool-call-rendered.html +13826 -0
- package/skills/archify/migrations/workflow-v2.mjs +280 -0
- package/skills/archify/package.json +17 -0
- package/skills/archify/recipes/scenarios.mjs +430 -0
- package/skills/archify/references/architecture-layout-repair.md +18 -0
- package/skills/archify/references/authoring-contract.md +419 -0
- package/skills/archify/references/authoring-defaults.md +41 -0
- package/skills/archify/references/brand-marks.md +82 -0
- package/skills/archify/references/delivery-contract.md +588 -0
- package/skills/archify/references/repository-authoring.md +105 -0
- package/skills/archify/references/update-awareness.md +12 -0
- package/skills/archify/references/viewer-runtime.md +35 -0
- package/skills/archify/renderers/architecture/grid.mjs +62 -0
- package/skills/archify/renderers/architecture/labels.mjs +215 -0
- package/skills/archify/renderers/architecture/render-architecture.mjs +1001 -0
- package/skills/archify/renderers/architecture/routing.mjs +1157 -0
- package/skills/archify/renderers/dataflow/README.md +105 -0
- package/skills/archify/renderers/dataflow/render-dataflow.mjs +544 -0
- package/skills/archify/renderers/lifecycle/README.md +171 -0
- package/skills/archify/renderers/lifecycle/grid-routing.mjs +429 -0
- package/skills/archify/renderers/lifecycle/render-lifecycle.mjs +938 -0
- package/skills/archify/renderers/sequence/README.md +129 -0
- package/skills/archify/renderers/sequence/render-sequence.mjs +526 -0
- package/skills/archify/renderers/shared/atomic-output.mjs +1982 -0
- package/skills/archify/renderers/shared/brand-marks.mjs +670 -0
- package/skills/archify/renderers/shared/cli.mjs +451 -0
- package/skills/archify/renderers/shared/desktop-readability.mjs +132 -0
- package/skills/archify/renderers/shared/diagnostics.mjs +173 -0
- package/skills/archify/renderers/shared/engineering-profiles.mjs +157 -0
- package/skills/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
- package/skills/archify/renderers/shared/generated-validators.mjs +134 -0
- package/skills/archify/renderers/shared/geometry.mjs +1941 -0
- package/skills/archify/renderers/shared/i18n.mjs +603 -0
- package/skills/archify/renderers/shared/layout-report.mjs +42 -0
- package/skills/archify/renderers/shared/legend.mjs +217 -0
- package/skills/archify/renderers/shared/output-path.mjs +440 -0
- package/skills/archify/renderers/shared/path-semantics.mjs +1017 -0
- package/skills/archify/renderers/shared/portable-path.mjs +408 -0
- package/skills/archify/renderers/shared/repository-evidence.mjs +352 -0
- package/skills/archify/renderers/shared/repository-location.mjs +59 -0
- package/skills/archify/renderers/shared/route-quality.mjs +614 -0
- package/skills/archify/renderers/shared/sidecar-path.mjs +38 -0
- package/skills/archify/renderers/shared/spatial-grid.mjs +66 -0
- package/skills/archify/renderers/shared/text-fit.mjs +105 -0
- package/skills/archify/renderers/shared/utils.mjs +254 -0
- package/skills/archify/renderers/shared/validator.mjs +86 -0
- package/skills/archify/renderers/workflow/README.md +301 -0
- package/skills/archify/renderers/workflow/render-workflow.mjs +37 -0
- package/skills/archify/renderers/workflow/workflow-compiler.mjs +4913 -0
- package/skills/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
- package/skills/archify/schemas/README.md +256 -0
- package/skills/archify/schemas/architecture.schema.json +152 -0
- package/skills/archify/schemas/common.schema.json +181 -0
- package/skills/archify/schemas/dataflow.schema.json +254 -0
- package/skills/archify/schemas/lifecycle.schema.json +280 -0
- package/skills/archify/schemas/sequence.schema.json +234 -0
- package/skills/archify/schemas/workflow.schema.json +439 -0
- package/skills/archify/scripts/check-render-output.mjs +1388 -0
- package/skills/archify/scripts/check-update.mjs +1710 -0
- package/skills/archify/scripts/delivery-update-child.mjs +23 -0
- package/skills/archify/scripts/render-examples.mjs +26 -0
- package/skills/archify/scripts/update-contract.mjs +182 -0
- package/skills/archify/skill-release.json +10 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { throwDiagnosticError, withDiagnosticRecordingSuppressed } from './diagnostics.mjs';
|
|
5
|
+
import { sameEntry } from './path-semantics.mjs';
|
|
6
|
+
import { parseRepositoryRemote, redactRepositoryRemote, repositorySourceHref } from './repository-location.mjs';
|
|
7
|
+
|
|
8
|
+
const FULL_SHA_RE = /^[a-f0-9]{40}$/i;
|
|
9
|
+
const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/;
|
|
10
|
+
const MAX_SOURCE_BYTES = 16 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function evidenceFailure(code, message, { subject = {}, evidence = {}, supportedFixes = [] } = {}) {
|
|
13
|
+
throwDiagnosticError(message, [{
|
|
14
|
+
code,
|
|
15
|
+
severity: 'error',
|
|
16
|
+
message,
|
|
17
|
+
subject: { surface: 'repository-evidence', ...subject },
|
|
18
|
+
evidence,
|
|
19
|
+
supportedFixes,
|
|
20
|
+
}]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runGit(repoRoot, args) {
|
|
24
|
+
// 固定 SHA 的来源必须读取原始对象,不能使用本地 replacement refs 的替换内容。
|
|
25
|
+
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, ...args], {
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
maxBuffer: MAX_SOURCE_BYTES,
|
|
28
|
+
});
|
|
29
|
+
if (result.error) evidenceFailure('repository-evidence/git-unavailable', `Could not run Git: ${result.error.message}`, {
|
|
30
|
+
evidence: { reason: result.error.message },
|
|
31
|
+
supportedFixes: ['install Git and ensure it is available on PATH'],
|
|
32
|
+
});
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check types in one session, then read only blobs whose cited lines need
|
|
37
|
+
// verification. Path-only references never require loading the file contents.
|
|
38
|
+
function prefetchBlobs(repoRoot, objectNeedsContent) {
|
|
39
|
+
const blobs = readBatchObjects(repoRoot, [...objectNeedsContent.keys()], false);
|
|
40
|
+
if (!blobs) return null;
|
|
41
|
+
const readable = [...objectNeedsContent].filter(([object, needsContent]) => {
|
|
42
|
+
const blob = blobs.get(object);
|
|
43
|
+
return needsContent && blob?.type === 'blob' && blob.size <= MAX_SOURCE_BYTES;
|
|
44
|
+
}).map(([object]) => object);
|
|
45
|
+
const contents = readBatchObjects(repoRoot, readable, true);
|
|
46
|
+
if (contents) for (const [object, blob] of contents) blobs.set(object, blob);
|
|
47
|
+
// Failed or oversized reads fall back in source order to the original
|
|
48
|
+
// per-file path, preserving its size limit and diagnostic behavior.
|
|
49
|
+
return blobs;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readBatchObjects(repoRoot, objects, includeContent) {
|
|
53
|
+
if (!objects.length) return new Map();
|
|
54
|
+
const mode = includeContent ? '--batch' : '--batch-check';
|
|
55
|
+
const result = spawnSync('git', ['--no-replace-objects', '-C', repoRoot, 'cat-file', mode], {
|
|
56
|
+
input: objects.join('\n') + '\n',
|
|
57
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
58
|
+
});
|
|
59
|
+
if (result.error || result.status !== 0 || !Buffer.isBuffer(result.stdout)) return null;
|
|
60
|
+
const buffer = result.stdout;
|
|
61
|
+
const blobs = new Map();
|
|
62
|
+
let cursor = 0;
|
|
63
|
+
for (const object of objects) {
|
|
64
|
+
const newline = buffer.indexOf(0x0a, cursor);
|
|
65
|
+
if (newline < 0) return null;
|
|
66
|
+
const header = buffer.toString('utf8', cursor, newline);
|
|
67
|
+
cursor = newline + 1;
|
|
68
|
+
if (header.endsWith(' missing')) {
|
|
69
|
+
blobs.set(object, { missing: true });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const parts = header.split(' ');
|
|
73
|
+
const size = Number(parts[2]);
|
|
74
|
+
if (parts.length !== 3 || !Number.isSafeInteger(size) || size < 0) return null;
|
|
75
|
+
const blob = { type: parts[1], size };
|
|
76
|
+
if (includeContent) {
|
|
77
|
+
if (cursor + size >= buffer.length || buffer[cursor + size] !== 0x0a) return null;
|
|
78
|
+
blob.content = buffer.toString('utf8', cursor, cursor + size);
|
|
79
|
+
cursor += size + 1;
|
|
80
|
+
}
|
|
81
|
+
blobs.set(object, blob);
|
|
82
|
+
}
|
|
83
|
+
return blobs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function gitValue(repoRoot, args, failure) {
|
|
87
|
+
const result = runGit(repoRoot, args);
|
|
88
|
+
if (result.status !== 0) evidenceFailure('repository-evidence/git-command', failure, {
|
|
89
|
+
evidence: { gitArgs: args, exitCode: result.status },
|
|
90
|
+
supportedFixes: ['use the intended local Git repository and verify its origin and revision'],
|
|
91
|
+
});
|
|
92
|
+
return result.stdout.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function verifiedSourcePath(value, where) {
|
|
96
|
+
const sourcePath = String(value || '');
|
|
97
|
+
// path-contract-allow: git-path -- Git tree entries use repository-relative POSIX syntax.
|
|
98
|
+
if (!sourcePath || sourcePath.startsWith('/') || sourcePath.includes('\\') || CONTROL_CHARACTER_RE.test(sourcePath)) {
|
|
99
|
+
evidenceFailure('repository-evidence/path-invalid', `${where} must be a repo-relative POSIX path.`, {
|
|
100
|
+
subject: { path: where },
|
|
101
|
+
evidence: { authoredPath: sourcePath },
|
|
102
|
+
supportedFixes: ['use a repository-relative path with forward slashes'],
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const segments = sourcePath.split('/');
|
|
106
|
+
if (segments.some((segment) => !segment || segment === '.' || segment === '..') || segments[0] === '.git') {
|
|
107
|
+
evidenceFailure('repository-evidence/path-escape', `${where} must stay inside the repository and may not address .git.`, {
|
|
108
|
+
subject: { path: where },
|
|
109
|
+
evidence: { authoredPath: sourcePath },
|
|
110
|
+
supportedFixes: ['remove empty, dot, parent, or .git path segments'],
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return segments.join('/');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sourceLineCount(content) {
|
|
117
|
+
if (!content.length) return 0;
|
|
118
|
+
const lines = content.split(/\r\n|\n|\r/);
|
|
119
|
+
return lines.length - (/(?:\r\n|\n|\r)$/.test(content) ? 1 : 0);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Every diagram type carries its nodes under a different property name, and
|
|
123
|
+
// source evidence is authored on those nodes. One table keeps the verification
|
|
124
|
+
// below identical for all five types instead of branching per type: the only
|
|
125
|
+
// per-type fact is which array to read and which JSON pointer to quote back.
|
|
126
|
+
const EVIDENCE_NODE_COLLECTIONS = {
|
|
127
|
+
architecture: 'components',
|
|
128
|
+
workflow: 'nodes',
|
|
129
|
+
sequence: 'participants',
|
|
130
|
+
dataflow: 'nodes',
|
|
131
|
+
lifecycle: 'states',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
function evidenceNodes(diagramType, diagram) {
|
|
135
|
+
const collection = EVIDENCE_NODE_COLLECTIONS[diagramType];
|
|
136
|
+
if (!collection) return null;
|
|
137
|
+
return { collection, nodes: Array.isArray(diagram?.[collection]) ? diagram[collection] : [] };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function hasRepositoryEvidence(diagramType, diagram) {
|
|
141
|
+
const authored = evidenceNodes(diagramType, diagram);
|
|
142
|
+
if (!authored) return false;
|
|
143
|
+
return Boolean(diagram?.meta?.repository) || authored.nodes.some((node) => Array.isArray(node?.sources) && node.sources.length);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function verifyRepositoryEvidence(diagramType, diagram, repoRootInput) {
|
|
147
|
+
if (!hasRepositoryEvidence(diagramType, diagram)) return null;
|
|
148
|
+
const { collection, nodes: authoredNodes } = evidenceNodes(diagramType, diagram);
|
|
149
|
+
|
|
150
|
+
const repository = diagram.meta?.repository;
|
|
151
|
+
if (!repository) evidenceFailure('repository-evidence/repository-required', 'Repository evidence requires /meta/repository.', {
|
|
152
|
+
subject: { path: '/meta/repository', diagramType, collection },
|
|
153
|
+
supportedFixes: [`add the pinned repository metadata or remove /${collection} sources`],
|
|
154
|
+
});
|
|
155
|
+
if (!FULL_SHA_RE.test(repository.revision || '')) {
|
|
156
|
+
evidenceFailure('repository-evidence/revision-invalid', '/meta/repository/revision must be a full 40-character commit SHA.', {
|
|
157
|
+
subject: { path: '/meta/repository/revision' },
|
|
158
|
+
evidence: { revision: repository.revision },
|
|
159
|
+
supportedFixes: ['pin one full 40-character commit SHA'],
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const location = parseRepositoryRemote(repository.url, { authored: true });
|
|
163
|
+
if (!location) {
|
|
164
|
+
// A filesystem path is the common authoring mistake: the field carries the
|
|
165
|
+
// remote origin identity, which `git remote get-url origin` reports.
|
|
166
|
+
const filesystemPath = /^(?:[\\/]|~|\.{1,2}(?:[\\/]|$)|[A-Za-z]:[\\/])/.test(String(repository.url ?? ''));
|
|
167
|
+
evidenceFailure('repository-evidence/url-invalid', '/meta/repository/url must be a credential-free HTTP(S) or Git SSH repository address without query, fragment, or dot segments.', {
|
|
168
|
+
subject: { path: '/meta/repository/url' },
|
|
169
|
+
evidence: filesystemPath ? { authoredValueLooksLike: 'local filesystem path; the expected value is the remote origin address' } : {},
|
|
170
|
+
supportedFixes: ['run `git remote get-url origin` inside --repo-root and declare that credential-free address', 'use link_mode: local-only for internal repositories'],
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const linkMode = repository.link_mode ?? 'web';
|
|
174
|
+
if (!['web', 'local-only'].includes(linkMode)) evidenceFailure('repository-evidence/link-mode-invalid', 'Repository link_mode must be web or local-only.');
|
|
175
|
+
if (repository.provider !== undefined && (!['github', 'gitee'].includes(repository.provider) || repository.provider !== location.provider)) {
|
|
176
|
+
evidenceFailure('repository-evidence/provider-invalid', 'Repository provider must match its supported public host (github.com or gitee.com).', {
|
|
177
|
+
subject: { path: '/meta/repository/provider' },
|
|
178
|
+
supportedFixes: ['use the matching provider or omit provider and select link_mode: local-only'],
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (linkMode === 'web' && (!location.provider || location.protocol !== 'https:' || location.endpoint !== 'standard' || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(location.path))) {
|
|
182
|
+
evidenceFailure('repository-evidence/links-unsupported', 'Web source links require a canonical GitHub or Gitee HTTPS owner/repository URL.', {
|
|
183
|
+
subject: { path: '/meta/repository/url' },
|
|
184
|
+
supportedFixes: ['use a canonical GitHub or Gitee URL, or select link_mode: local-only to retain local verification without web links'],
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (!repoRootInput) {
|
|
188
|
+
evidenceFailure('repository-evidence/root-required', 'This diagram declares source evidence. Pass --repo-root <repository> so Archify can verify it before rendering.', {
|
|
189
|
+
subject: { path: '/meta/repository' },
|
|
190
|
+
supportedFixes: ['pass --repo-root with the matching local Git checkout'],
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const requestedRoot = path.resolve(repoRootInput);
|
|
195
|
+
let realRoot;
|
|
196
|
+
try {
|
|
197
|
+
realRoot = fs.realpathSync(requestedRoot);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
evidenceFailure('repository-evidence/root-unreadable', `Could not resolve evidence repository root "${requestedRoot}": ${error.message}`, {
|
|
200
|
+
subject: { repoRoot: requestedRoot },
|
|
201
|
+
evidence: { reason: error.message },
|
|
202
|
+
supportedFixes: ['pass one readable local repository directory'],
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
const gitRoot = gitValue(realRoot, ['rev-parse', '--show-toplevel'], `Evidence root "${realRoot}" is not a Git repository.`);
|
|
206
|
+
const rootIdentity = sameEntry(realRoot, gitRoot);
|
|
207
|
+
if (rootIdentity.status === 'unknown') {
|
|
208
|
+
evidenceFailure('repository-evidence/root-identity-indeterminate', 'Could not determine whether the evidence root is the Git top-level directory.', {
|
|
209
|
+
subject: { repoRoot: realRoot },
|
|
210
|
+
evidence: { gitTopLevel: gitRoot, relation: rootIdentity.reason },
|
|
211
|
+
supportedFixes: ['pass the readable Git top-level directory using its canonical filesystem path'],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (rootIdentity.status === 'different') {
|
|
215
|
+
evidenceFailure('repository-evidence/root-not-top-level', `Evidence root must be the Git top-level directory: ${gitRoot}`, {
|
|
216
|
+
subject: { repoRoot: realRoot },
|
|
217
|
+
evidence: { gitTopLevel: gitRoot },
|
|
218
|
+
supportedFixes: [`pass --repo-root ${gitRoot}`],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const origin = gitValue(realRoot, ['remote', 'get-url', 'origin'], 'Evidence repository must have an origin remote.');
|
|
222
|
+
if (parseRepositoryRemote(origin)?.identity !== location.identity) {
|
|
223
|
+
const safeOrigin = redactRepositoryRemote(origin);
|
|
224
|
+
evidenceFailure('repository-evidence/origin-mismatch', `Evidence repository origin ${JSON.stringify(safeOrigin)} does not match ${JSON.stringify(repository.url)}.`, {
|
|
225
|
+
subject: { repoRoot: realRoot },
|
|
226
|
+
evidence: { localOrigin: safeOrigin, authoredRepository: repository.url },
|
|
227
|
+
supportedFixes: ['use the matching local checkout or correct the authored repository URL'],
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const revision = repository.revision.toLowerCase();
|
|
232
|
+
const commit = runGit(realRoot, ['cat-file', '-e', `${revision}^{commit}`]);
|
|
233
|
+
if (commit.status !== 0) {
|
|
234
|
+
evidenceFailure('repository-evidence/revision-unavailable', `Evidence revision ${revision} is not available in the local repository.`, {
|
|
235
|
+
subject: { repoRoot: realRoot },
|
|
236
|
+
evidence: { revision },
|
|
237
|
+
supportedFixes: ['fetch the pinned commit or pin an available full commit SHA'],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The batch is an optimization only: every path, line-range, file and line
|
|
242
|
+
// check still runs in source order in the verification loop below, so a
|
|
243
|
+
// citation the batch cannot answer for never reorders the first diagnostic.
|
|
244
|
+
const citedObjects = new Map();
|
|
245
|
+
for (const [nodeIndex, node] of authoredNodes.entries()) {
|
|
246
|
+
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
|
|
247
|
+
for (const [sourceIndex, authored] of node.sources.entries()) {
|
|
248
|
+
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
|
|
249
|
+
let sourcePath;
|
|
250
|
+
try {
|
|
251
|
+
sourcePath = withDiagnosticRecordingSuppressed(() => verifiedSourcePath(authored.path, `${at}/path`));
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
const object = `${revision}:${sourcePath}`;
|
|
256
|
+
citedObjects.set(object, citedObjects.get(object) || Boolean(authored.line));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const prefetchedBlobs = prefetchBlobs(realRoot, citedObjects);
|
|
260
|
+
|
|
261
|
+
const nodes = Object.create(null);
|
|
262
|
+
let referenceCount = 0;
|
|
263
|
+
for (const [nodeIndex, node] of authoredNodes.entries()) {
|
|
264
|
+
if (!Array.isArray(node.sources) || node.sources.length === 0) continue;
|
|
265
|
+
// `componentId` shipped with the architecture-only path; keep it beside the
|
|
266
|
+
// type-neutral `nodeId` so existing agent handling stays valid.
|
|
267
|
+
const nodeSubject = collection === 'components'
|
|
268
|
+
? { diagramType, collection, nodeId: node.id, componentId: node.id }
|
|
269
|
+
: { diagramType, collection, nodeId: node.id };
|
|
270
|
+
const verified = [];
|
|
271
|
+
for (const [sourceIndex, authored] of node.sources.entries()) {
|
|
272
|
+
const at = `/${collection}/${nodeIndex}/sources/${sourceIndex}`;
|
|
273
|
+
const where = `${at}/path`;
|
|
274
|
+
const source = {
|
|
275
|
+
path: verifiedSourcePath(authored.path, where),
|
|
276
|
+
...(authored.line ? { line: authored.line } : {}),
|
|
277
|
+
...(authored.end_line ? { endLine: authored.end_line } : {}),
|
|
278
|
+
...(authored.label ? { label: authored.label } : {}),
|
|
279
|
+
};
|
|
280
|
+
if (source.endLine && !source.line) {
|
|
281
|
+
evidenceFailure('repository-evidence/line-required', `${at}/end_line requires line.`, {
|
|
282
|
+
subject: { path: `${at}/end_line`, ...nodeSubject },
|
|
283
|
+
supportedFixes: ['add line or remove end_line'],
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (source.endLine && source.endLine < source.line) {
|
|
287
|
+
evidenceFailure('repository-evidence/line-range-invalid', `${at}/end_line must be greater than or equal to line.`, {
|
|
288
|
+
subject: { path: at, ...nodeSubject },
|
|
289
|
+
evidence: { line: source.line, endLine: source.endLine },
|
|
290
|
+
supportedFixes: ['use an end_line greater than or equal to line'],
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const object = `${revision}:${source.path}`;
|
|
294
|
+
const prefetched = prefetchedBlobs ? prefetchedBlobs.get(object) : undefined;
|
|
295
|
+
const objectIsBlob = prefetched
|
|
296
|
+
? !prefetched.missing && prefetched.type === 'blob'
|
|
297
|
+
: (() => {
|
|
298
|
+
const type = runGit(realRoot, ['cat-file', '-t', object]);
|
|
299
|
+
return type.status === 0 && type.stdout.trim() === 'blob';
|
|
300
|
+
})();
|
|
301
|
+
if (!objectIsBlob) {
|
|
302
|
+
evidenceFailure('repository-evidence/file-missing', `${where} does not identify a file at revision ${revision}.`, {
|
|
303
|
+
subject: { path: where, ...nodeSubject },
|
|
304
|
+
evidence: { sourcePath: source.path, revision },
|
|
305
|
+
supportedFixes: ['use a file path that exists at the pinned revision'],
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
if (source.line) {
|
|
309
|
+
const content = prefetched && Object.hasOwn(prefetched, 'content')
|
|
310
|
+
? { status: 0, stdout: prefetched.content }
|
|
311
|
+
: runGit(realRoot, ['show', object]);
|
|
312
|
+
if (content.status !== 0) evidenceFailure('repository-evidence/file-unreadable', `${where} could not be read at revision ${revision}.`, {
|
|
313
|
+
subject: { path: where, ...nodeSubject },
|
|
314
|
+
evidence: { sourcePath: source.path, revision },
|
|
315
|
+
supportedFixes: ['verify the pinned blob is readable in the local checkout'],
|
|
316
|
+
});
|
|
317
|
+
const lineCount = sourceLineCount(content.stdout);
|
|
318
|
+
const requestedLine = source.endLine || source.line;
|
|
319
|
+
if (requestedLine > lineCount) {
|
|
320
|
+
evidenceFailure('repository-evidence/line-out-of-range', `${at} requests line ${requestedLine}, but ${source.path} has ${lineCount} lines at revision ${revision}.`, {
|
|
321
|
+
subject: { path: at, ...nodeSubject },
|
|
322
|
+
evidence: { sourcePath: source.path, requestedLine, lineCount, revision },
|
|
323
|
+
supportedFixes: ['use a line range that exists at the pinned revision'],
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
verified.push({ ...source, ...(linkMode === 'web' ? { href: repositorySourceHref(location.provider, location.url, revision, source) } : {}) });
|
|
328
|
+
referenceCount += 1;
|
|
329
|
+
}
|
|
330
|
+
nodes[node.id] = verified;
|
|
331
|
+
}
|
|
332
|
+
if (referenceCount === 0) {
|
|
333
|
+
evidenceFailure('repository-evidence/source-required', `/meta/repository requires at least one /${collection} source reference.`, {
|
|
334
|
+
subject: { path: '/meta/repository', diagramType, collection },
|
|
335
|
+
supportedFixes: [`add at least one verified /${collection} source or remove repository metadata`],
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
schemaVersion: 1,
|
|
341
|
+
verified: true,
|
|
342
|
+
repository: {
|
|
343
|
+
url: location.url,
|
|
344
|
+
revision,
|
|
345
|
+
shortRevision: revision.slice(0, 7),
|
|
346
|
+
label: location.provider === 'github' ? location.path : location.url.replace(/^(?:https?:\/\/|ssh:\/\/git@|git@)/, ''),
|
|
347
|
+
...(linkMode === 'web' ? { href: `${location.url}/tree/${revision}` } : { linkMode }),
|
|
348
|
+
},
|
|
349
|
+
referenceCount,
|
|
350
|
+
nodes,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Repository identity and forge links are independent of local Git object checks.
|
|
2
|
+
// This module never contacts a remote server or reads the user's SSH config.
|
|
3
|
+
export function parseRepositoryRemote(value, { authored = false } = {}) {
|
|
4
|
+
if (typeof value !== 'string') return null;
|
|
5
|
+
const raw = authored ? value : value.trim();
|
|
6
|
+
if (!raw || /[\s\\\u0000-\u001f\u007f?#]/.test(raw)) return null;
|
|
7
|
+
const scp = raw.match(/^git@([^/:]+):(.+)$/);
|
|
8
|
+
const scpAbsolute = Boolean(scp && scp[2].startsWith('/'));
|
|
9
|
+
const expanded = scp ? `ssh://git@${scp[1]}/${scp[2].replace(/^\//, '')}` : raw;
|
|
10
|
+
const match = expanded.match(/^(https?|ssh):\/\/([^/]+)\/(.+)$/i);
|
|
11
|
+
if (!match) return null;
|
|
12
|
+
// Validate the original path before URL parsing can collapse dot segments.
|
|
13
|
+
let segments;
|
|
14
|
+
try {
|
|
15
|
+
segments = match[3].replace(/\/$/, '').split('/');
|
|
16
|
+
// Git passes SCP paths literally; percent escapes are decoded only in URIs.
|
|
17
|
+
if (!scp) segments = segments.map(decodeURIComponent);
|
|
18
|
+
}
|
|
19
|
+
catch { return null; }
|
|
20
|
+
if (segments.some((part) => !part || part === '.' || part === '..' || /[/\\\s\u0000-\u001f\u007f?#]/.test(part))) return null;
|
|
21
|
+
let url;
|
|
22
|
+
try { url = new URL(expanded); } catch { return null; }
|
|
23
|
+
const protocol = url.protocol;
|
|
24
|
+
if (protocol === 'ssh:' && (url.username !== 'git' || url.password)) return null;
|
|
25
|
+
if (authored && protocol !== 'ssh:' && (url.username || url.password)) return null;
|
|
26
|
+
const hostname = url.hostname.toLowerCase();
|
|
27
|
+
if (!hostname) return null;
|
|
28
|
+
const provider = hostname === 'github.com' ? 'github' : hostname === 'gitee.com' ? 'gitee' : null;
|
|
29
|
+
const last = segments.length - 1;
|
|
30
|
+
if (provider) segments[last] = segments[last].replace(provider === 'github' ? /\.git$/i : /\.git$/, '');
|
|
31
|
+
if (!segments[last] || segments[last] === '.' || segments[last] === '..') return null;
|
|
32
|
+
const repositoryPath = segments.join('/');
|
|
33
|
+
// Only known forges map HTTPS and SSH to one repository namespace. Other
|
|
34
|
+
// hosts retain transport, port and remote-relative/absolute path semantics.
|
|
35
|
+
const port = url.port || (protocol === 'ssh:' ? '22' : protocol === 'https:' ? '443' : '80');
|
|
36
|
+
const endpoint = provider && ((protocol === 'https:' && port === '443') || (protocol === 'ssh:' && port === '22'))
|
|
37
|
+
? 'standard' : `${protocol}${port}`;
|
|
38
|
+
const pathKind = provider ? 'repository' : scp && !scpAbsolute ? 'relative' : 'absolute';
|
|
39
|
+
// path-contract-allow: url-path -- GitHub repository names are case-insensitive URL identities.
|
|
40
|
+
const identityPath = provider === 'github' ? repositoryPath.toLowerCase() : repositoryPath;
|
|
41
|
+
const encodedPath = segments.map(encodeURIComponent).join('/');
|
|
42
|
+
const canonicalUrl = scp ? `git@${hostname}:${scpAbsolute ? '/' : ''}${repositoryPath}`
|
|
43
|
+
: `${protocol}//${protocol === 'ssh:' ? 'git@' : ''}${url.host}/${encodedPath}`;
|
|
44
|
+
return { identity: JSON.stringify([hostname, endpoint, pathKind, identityPath]), url: canonicalUrl, provider, protocol, path: repositoryPath, endpoint };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function redactRepositoryRemote(value) {
|
|
48
|
+
return String(value || '')
|
|
49
|
+
.replace(/^((?:https?|ssh):\/\/)[^/]*@/i, '$1REDACTED@')
|
|
50
|
+
.replace(/[?#].*$/s, '?REDACTED');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function repositorySourceHref(provider, url, revision, source) {
|
|
54
|
+
const encodedPath = source.path.split('/').map(encodeURIComponent).join('/');
|
|
55
|
+
const end = source.endLine && source.endLine !== source.line
|
|
56
|
+
? `-${provider === 'github' ? 'L' : ''}${source.endLine}` : '';
|
|
57
|
+
const fragment = source.line ? `#L${source.line}${end}` : '';
|
|
58
|
+
return `${url}/blob/${revision}/${encodedPath}${fragment}`;
|
|
59
|
+
}
|