@plainconceptsplatform/agent-harness 3.8.2 → 3.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.
Files changed (148) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +551 -549
  3. package/cli/commands/join.js +259 -259
  4. package/cli/commands/migrate.js +310 -310
  5. package/cli/commands/shared.js +27 -27
  6. package/cli/commands/single.js +79 -79
  7. package/cli/commands/update.js +121 -121
  8. package/cli/commands/wizard.js +134 -134
  9. package/cli/fragments/archive/az.md +97 -97
  10. package/cli/fragments/archive/gh.md +96 -96
  11. package/cli/fragments/archive/gl.md +96 -96
  12. package/cli/fragments/archive/none.md +75 -75
  13. package/cli/fragments/guardrails/codegraph.md +5 -5
  14. package/cli/fragments/guardrails/humanizer.md +4 -4
  15. package/cli/fragments/guardrails/memory.md +4 -4
  16. package/cli/fragments/guardrails/rtk.md +3 -3
  17. package/cli/fragments/guardrails/simple-english.md +4 -4
  18. package/cli/fragments/ops-backlog/az.md +28 -28
  19. package/cli/fragments/ops-backlog/gh.md +28 -28
  20. package/cli/fragments/ops-backlog/jira.md +28 -28
  21. package/cli/fragments/ops-review/az.md +62 -62
  22. package/cli/fragments/ops-review/gh.md +51 -51
  23. package/cli/fragments/ops-review/gl.md +55 -55
  24. package/cli/fragments/ops-ship/az.md +81 -81
  25. package/cli/fragments/ops-ship/gh.md +67 -67
  26. package/cli/fragments/ops-ship/gl.md +84 -84
  27. package/cli/index.js +116 -116
  28. package/cli/presets/agents-content.json +34 -34
  29. package/cli/presets/browser.json +10 -10
  30. package/cli/presets/clean.json +21 -21
  31. package/cli/presets/models.json +68 -68
  32. package/cli/presets/optimization.json +32 -32
  33. package/cli/presets/platforms.json +76 -76
  34. package/cli/presets/source.json +23 -23
  35. package/cli/steps/browser/index.js +48 -48
  36. package/cli/steps/clean/index.js +120 -120
  37. package/cli/steps/copy/agents.js +176 -160
  38. package/cli/steps/copy/commands.js +79 -79
  39. package/cli/steps/copy/fullstack-engineer.js +81 -89
  40. package/cli/steps/copy/index.js +88 -88
  41. package/cli/steps/copy/opencode-json.js +324 -324
  42. package/cli/steps/copy/skills.js +316 -316
  43. package/cli/steps/metadata/index.js +115 -115
  44. package/cli/steps/models/format.js +88 -88
  45. package/cli/steps/models/index.js +64 -64
  46. package/cli/steps/models/write.js +38 -38
  47. package/cli/steps/openspec/index.js +136 -136
  48. package/cli/steps/optimization/codegraph.js +141 -141
  49. package/cli/steps/optimization/detect.js +66 -66
  50. package/cli/steps/optimization/humanizer.js +17 -17
  51. package/cli/steps/optimization/index.js +158 -158
  52. package/cli/steps/optimization/memory.js +91 -91
  53. package/cli/steps/optimization/patch-guardrails.js +111 -111
  54. package/cli/steps/optimization/simple-english.js +17 -17
  55. package/cli/steps/optimization/skills-lock.js +30 -30
  56. package/cli/steps/platform/index.js +109 -109
  57. package/cli/steps/source/index.js +123 -123
  58. package/cli/utils/agent-color.js +111 -111
  59. package/cli/utils/copy.js +134 -134
  60. package/cli/utils/exec-spinner.js +47 -47
  61. package/cli/utils/exec.js +134 -134
  62. package/cli/utils/legacy-check.js +30 -30
  63. package/cli/utils/models-cache.js +58 -58
  64. package/cli/utils/models-pricing.js +42 -42
  65. package/cli/utils/paths.js +82 -82
  66. package/cli/utils/process.js +3 -3
  67. package/cli/utils/terminal.js +6 -6
  68. package/cli/utils/update-manifest.js +75 -75
  69. package/harness/.opencode/_gitignore +12 -10
  70. package/harness/.opencode/commands/init.md +5 -5
  71. package/harness/.opencode/commands/make-architecture.md +5 -5
  72. package/harness/.opencode/commands/make-design.md +5 -5
  73. package/harness/.opencode/commands/make-engineer.md +5 -5
  74. package/harness/.opencode/commands/make-guardrails.md +5 -5
  75. package/harness/.opencode/commands/make-user-model.md +5 -5
  76. package/harness/.opencode/commands/ops-backlog.md +10 -10
  77. package/harness/.opencode/commands/ops-review.md +10 -10
  78. package/harness/.opencode/commands/ops-ship.md +9 -9
  79. package/harness/.opencode/commands/plan-apply.md +9 -9
  80. package/harness/.opencode/commands/plan-archive.md +7 -7
  81. package/harness/.opencode/commands/plan-explore.md +11 -11
  82. package/harness/.opencode/commands/plan-goal.md +5 -5
  83. package/harness/.opencode/commands/plan-propose.md +11 -11
  84. package/harness/.opencode/commands/plan-quick.md +7 -7
  85. package/harness/.opencode/commands/plan-story.md +11 -11
  86. package/harness/.opencode/commands/repo-audit.md +7 -7
  87. package/harness/.opencode/commands/repo-help.md +5 -5
  88. package/harness/.opencode/commands/repo-initialize.md +5 -5
  89. package/harness/.opencode/commands/repo-onboard.md +5 -5
  90. package/harness/.opencode/commands/repo-verify.md +5 -5
  91. package/harness/.opencode/package.json +2 -1
  92. package/harness/.opencode/plugins/pc-commit-coauthor.js +41 -41
  93. package/harness/.opencode/plugins/pc-subagent-monitor.js +184 -184
  94. package/harness/.opencode/plugins/pc-subagent-tiers-core.js +337 -0
  95. package/harness/.opencode/plugins/pc-subagent-tiers.js +34 -345
  96. package/harness/.opencode/plugins/pc-system-reminders.js +424 -424
  97. package/harness/.opencode/scripts/check-diff.mjs +305 -305
  98. package/harness/.opencode/scripts/code-rules.mjs +288 -288
  99. package/harness/.opencode/scripts/harness-doctor.mjs +52 -0
  100. package/harness/.opencode/scripts/harness-doctor.test.mjs +58 -0
  101. package/harness/.opencode/scripts/platform-contract.mjs +366 -366
  102. package/harness/.opencode/scripts/sync-agents.mjs +29 -0
  103. package/harness/AGENTS.md +55 -49
  104. package/harness/ARCHITECTURE.md +13 -13
  105. package/harness/DESIGN.md +13 -13
  106. package/harness/opencode.jsonc +36 -36
  107. package/harness/openspec/config.yaml +20 -20
  108. package/harness/skills-lock.json +17 -17
  109. package/package.json +12 -14
  110. package/skills/README.md +18 -18
  111. package/skills/agent-harness-cli/SKILL.md +86 -86
  112. package/skills/browser-automation/SKILL.md +72 -72
  113. package/skills/pc-guardrails-generic/SKILL.md +53 -53
  114. package/skills/pc-guardrails-project/SKILL.md +8 -8
  115. package/skills/pc-make-architecture/SKILL.md +33 -33
  116. package/skills/pc-make-architecture/structure-template.md +64 -64
  117. package/skills/pc-make-design/SKILL.md +45 -45
  118. package/skills/pc-make-engineer/SKILL.md +59 -59
  119. package/skills/pc-make-engineer/signal-mapping.md +53 -53
  120. package/skills/pc-make-engineer/template.md +36 -36
  121. package/skills/pc-make-guardrails/SKILL.md +44 -44
  122. package/skills/pc-make-guardrails/category-reference.md +112 -112
  123. package/skills/pc-make-merge-risk-assess/SKILL.md +70 -70
  124. package/skills/pc-make-merge-risk-assess/category-reference.md +114 -114
  125. package/skills/pc-make-user-model/SKILL.md +56 -56
  126. package/skills/pc-ops-ship/SKILL.md +22 -22
  127. package/skills/pc-plan-apply/SKILL.md +90 -90
  128. package/skills/pc-plan-apply/simple-mode.md +21 -21
  129. package/skills/pc-plan-archive/SKILL.md +66 -66
  130. package/skills/pc-plan-explore/SKILL.md +21 -21
  131. package/skills/pc-plan-goal/SKILL.md +91 -91
  132. package/skills/pc-plan-goal/branching.md +39 -39
  133. package/skills/pc-plan-goal/failure-policy.md +30 -30
  134. package/skills/pc-plan-goal/output-mode.md +10 -10
  135. package/skills/pc-plan-goal/output.md +71 -71
  136. package/skills/pc-plan-propose/SKILL.md +127 -127
  137. package/skills/pc-plan-propose/task-annotation.md +39 -39
  138. package/skills/pc-plan-quick/SKILL.md +46 -46
  139. package/skills/pc-plan-story/SKILL.md +93 -93
  140. package/skills/pc-repo-audit/SKILL.md +44 -44
  141. package/skills/pc-repo-help/SKILL.md +87 -87
  142. package/skills/pc-repo-initialize/SKILL.md +119 -119
  143. package/skills/pc-repo-onboard/SKILL.md +32 -32
  144. package/skills/pc-repo-verify/SKILL.md +150 -150
  145. package/skills/pc-userstory-az/SKILL.md +71 -71
  146. package/skills/pc-userstory-browser/SKILL.md +64 -64
  147. package/skills/pc-userstory-gh/SKILL.md +63 -63
  148. package/skills/pc-userstory-jira/SKILL.md +74 -74
@@ -1,366 +1,366 @@
1
- import { execFileSync } from 'node:child_process'
2
- import fs from 'node:fs'
3
- import path from 'node:path'
4
-
5
- export const CONTRACT_FILE = '.platform/contract.yml'
6
- const DEFAULT_EXCEPTIONS = '.platform/exceptions.yml'
7
- const LEGACY_ANSWERS = '.copier-answers.yml'
8
- const LEGACY_MANIFEST = '.platform/manifest'
9
-
10
- export class ContractError extends Error {}
11
-
12
- function bracket(segment, start) {
13
- const end = segment.indexOf(']', start + 2)
14
- if (end === -1) return null
15
- let body = segment.slice(start + 1, end)
16
- const negated = body[0] === '!' || body[0] === '^'
17
- if (negated) body = body.slice(1)
18
- const ranges = []
19
- for (let i = 0; i < body.length; i++) {
20
- const from = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
21
- let to = from
22
- if (body[i + 1] === '-' && i + 2 < body.length) {
23
- i += 2
24
- to = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
25
- }
26
- ranges.push([from, to])
27
- }
28
- return { token: { negated, ranges }, end }
29
- }
30
-
31
- const STAR = Symbol('*')
32
-
33
- function segmentTokens(segment) {
34
- const tokens = []
35
- for (let i = 0; i < segment.length; i++) {
36
- const c = segment[i]
37
- const set = c === '[' ? bracket(segment, i) : null
38
- if (c === '*') {
39
- if (tokens.at(-1) !== STAR) tokens.push(STAR)
40
- } else if (c === '?') tokens.push({ negated: true, ranges: [] })
41
- else if (set) {
42
- tokens.push(set.token)
43
- i = set.end
44
- } else tokens.push(c === '\\' && i + 1 < segment.length ? segment[++i] : c)
45
- }
46
- return tokens
47
- }
48
-
49
- const tokenMatches = (token, c) => typeof token === 'string'
50
- ? token === c
51
- : token.ranges.some(([from, to]) => from <= c && c <= to) !== token.negated
52
-
53
- function matchSegment(tokens, text) {
54
- let t = 0
55
- let i = 0
56
- let star = -1
57
- let resume = 0
58
- while (i < text.length) {
59
- if (tokens[t] === STAR) {
60
- star = t++
61
- resume = i
62
- } else if (t < tokens.length && tokenMatches(tokens[t], text[i])) {
63
- t++
64
- i++
65
- } else if (star !== -1) {
66
- t = star + 1
67
- i = ++resume
68
- } else return false
69
- }
70
- while (tokens[t] === STAR) t++
71
- return t === tokens.length
72
- }
73
-
74
- // gitwildmatch over `/` segments; a RegExp built from contract text is a blocking finding in the apps' Semgrep scan.
75
- export function compileGlob(glob) {
76
- const directory = glob.endsWith('/')
77
- const body = directory ? glob.slice(0, -1) : glob
78
- const segments = (body.startsWith('/') ? body.slice(1) : body).split('/')
79
- return { anchored: body.includes('/'), directory, segments: segments.map(s => (s === '**' ? s : segmentTokens(s))) }
80
- }
81
-
82
- function matchFrom(segments, s, parts, p, directory) {
83
- if (s === segments.length) return parts.length - p >= (directory ? 1 : 0)
84
- if (segments[s] !== '**') return p < parts.length && matchSegment(segments[s], parts[p]) && matchFrom(segments, s + 1, parts, p + 1, directory)
85
- for (let next = s + 1 === segments.length ? p + 1 : p; next <= parts.length; next++) {
86
- if (matchFrom(segments, s + 1, parts, next, directory)) return true
87
- }
88
- return false
89
- }
90
-
91
- export function matchGlob(compiled, file) {
92
- const { anchored, directory, segments } = typeof compiled === 'string' ? compileGlob(compiled) : compiled
93
- const parts = file.split('/')
94
- for (let start = 0; start < (anchored ? 1 : parts.length); start++) {
95
- if (matchFrom(segments, 0, parts, start, directory)) return true
96
- }
97
- return false
98
- }
99
-
100
- const toRules = globs => globs.map(glob => {
101
- const negated = glob.startsWith('!')
102
- return { negated, glob: compileGlob(negated ? glob.slice(1) : glob) }
103
- })
104
-
105
- function matchesRules(rules, file) {
106
- let matched = false
107
- for (const rule of rules) if (matchGlob(rule.glob, file)) matched = !rule.negated
108
- return matched
109
- }
110
-
111
- function stripComment(line) {
112
- let quote = null
113
- for (let i = 0; i < line.length; i++) {
114
- const c = line[i]
115
- if (quote) quote = c === quote ? null : quote
116
- else if ((c === '"' || c === "'") && (i === 0 || /[\s[,]/.test(line[i - 1]))) quote = c
117
- else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
118
- }
119
- return line
120
- }
121
-
122
- function scalar(raw) {
123
- const value = raw.trim()
124
- const quoted = value.match(/^(["'])(.*)\1$/)
125
- if (quoted) return quoted[2]
126
- if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1).split(',').map(scalar).filter(item => item !== '')
127
- return /^-?\d+$/.test(value) ? Number(value) : value
128
- }
129
-
130
- const isItem = text => text === '-' || text.startsWith('- ')
131
-
132
- // The subset of YAML a contract and its exceptions use: nested mappings, block and flow lists, scalars.
133
- export function parseYaml(text, file) {
134
- const lines = []
135
- text.replace(/^\uFEFF/, '').split(/\r?\n/).forEach((raw, index) => {
136
- const body = stripComment(raw).trimEnd()
137
- if (body.trim()) lines.push({ indent: body.length - body.trimStart().length, text: body.trim(), number: index + 1 })
138
- })
139
- const fail = line => {
140
- throw new ContractError(`${file}: line ${line.number} cannot be read: \`${line.text}\`.`)
141
- }
142
-
143
- function block(start, indent) {
144
- const list = isItem(lines[start].text)
145
- const value = list ? [] : {}
146
- let i = start
147
- while (i < lines.length && lines[i].indent === indent) {
148
- const line = lines[i]
149
- if (isItem(line.text) !== list) {
150
- if (list) break
151
- fail(line)
152
- }
153
- if (list) {
154
- const rest = line.text.slice(1).trimStart()
155
- if (/^[^"'[{][^:]*:(\s|$)/.test(rest)) {
156
- lines[i] = { ...line, indent: indent + line.text.length - rest.length, text: rest }
157
- } else {
158
- i++
159
- if (rest || !(i < lines.length && lines[i].indent > indent)) {
160
- value.push(rest ? scalar(rest) : null)
161
- continue
162
- }
163
- }
164
- const [item, after] = block(i, lines[i].indent)
165
- value.push(item)
166
- i = after
167
- continue
168
- }
169
- const pair = line.text.match(/^([^:]+?)\s*:(?:\s+(.*))?$/)
170
- if (!pair) fail(line)
171
- const key = scalar(pair[1])
172
- i++
173
- const next = lines[i]
174
- if (pair[2]) value[key] = scalar(pair[2])
175
- else if (next && (next.indent > indent || (next.indent === indent && isItem(next.text)))) [value[key], i] = block(i, next.indent)
176
- else value[key] = null
177
- }
178
- if (i < lines.length && lines[i].indent > indent) fail(lines[i])
179
- return [value, i]
180
- }
181
-
182
- if (!lines.length) return null
183
- const [value, end] = block(0, lines[0].indent)
184
- if (end < lines.length) fail(lines[end])
185
- return value
186
- }
187
-
188
- const isText = value => typeof value === 'string' && value.trim() !== ''
189
- const isMapping = value => value !== null && typeof value === 'object' && !Array.isArray(value)
190
- const invalid = (file, field, problem) => new ContractError(`${file}: \`${field}\` ${problem}`)
191
-
192
- function mapping(value, file, field, required = false) {
193
- if (value == null && !required) return {}
194
- if (!isMapping(value)) throw invalid(file, field, required ? 'is required and must be a mapping.' : 'must be a mapping.')
195
- return value
196
- }
197
-
198
- function text(value, file, field, fallback) {
199
- if (value == null && fallback !== undefined) return fallback
200
- if (!isText(value)) throw invalid(file, field, fallback === undefined ? 'is required and must be a non-empty string.' : 'must be a non-empty string.')
201
- return value
202
- }
203
-
204
- function textList(value, file, field) {
205
- if (value == null) return []
206
- const list = Array.isArray(value) ? value : [value]
207
- if (!list.every(isText)) throw invalid(file, field, 'must be a list of non-empty strings.')
208
- return list
209
- }
210
-
211
- export function readContract(source, file = CONTRACT_FILE) {
212
- const raw = parseYaml(source, file)
213
- if (!isMapping(raw)) throw new ContractError(`${file}: expected a mapping with \`version\`, \`source\` and \`owned\`.`)
214
- if (raw.version == null) throw invalid(file, 'version', 'is required.')
215
- if (String(raw.version) !== '1') throw invalid(file, 'version', `is ${raw.version}; this harness reads version 1.`)
216
- const origin = mapping(raw.source, file, 'source', true)
217
- const url = text(origin.url, file, 'source.url')
218
- const owned = textList(raw.owned, file, 'owned')
219
- if (!owned.length) throw invalid(file, 'owned', 'is required and must list at least one path.')
220
- const reuse = mapping(raw.reuse, file, 'reuse')
221
- const sync = mapping(raw.sync, file, 'sync')
222
- const version = sync.version == null ? null : mapping(sync.version, file, 'sync.version')
223
- return {
224
- file,
225
- source: { name: text(origin.name, file, 'source.name'), url, changes: text(origin.changes, file, 'source.changes', url) },
226
- owned,
227
- reuse: { docs: textList(reuse.docs, file, 'reuse.docs'), packages: textList(reuse.packages, file, 'reuse.packages'), names: textList(reuse.names, file, 'reuse.names') },
228
- exceptions: text(raw.exceptions, file, 'exceptions', DEFAULT_EXCEPTIONS),
229
- sync: {
230
- marker: text(sync.marker, file, 'sync.marker', null),
231
- version: version && { file: text(version.file, file, 'sync.version.file'), key: text(version.key, file, 'sync.version.key') },
232
- update: text(sync.update, file, 'sync.update', null),
233
- },
234
- }
235
- }
236
-
237
- function readText(file) {
238
- try {
239
- return fs.readFileSync(file, 'utf8')
240
- } catch {
241
- return null
242
- }
243
- }
244
-
245
- // What harness 3.7.0 read before contract.yml existed: Copier answers plus a manifest of owned globs.
246
- function legacyContract(root) {
247
- const answers = readText(path.join(root, LEGACY_ANSWERS))
248
- const manifest = answers == null ? null : readText(path.join(root, LEGACY_MANIFEST))
249
- if (manifest == null) return null
250
- const srcLine = answers.match(/^_src_path:(.*)$/m)
251
- const url = srcLine ? scalar(stripComment(srcLine[1])) || null : null
252
- const owned = manifest.split(/\r?\n/).map(line => line.replace(/(?<!\\)\s+$/, '')).filter(line => line && !line.startsWith('#'))
253
- return {
254
- file: LEGACY_MANIFEST,
255
- source: { name: url ? url.replace(/\.git$/, '').split(/[\\/:]/).filter(Boolean).pop() : 'the upstream template', url, changes: url },
256
- owned,
257
- reuse: { docs: [], packages: [], names: [] },
258
- exceptions: DEFAULT_EXCEPTIONS,
259
- sync: { marker: LEGACY_ANSWERS, version: { file: LEGACY_ANSWERS, key: '_commit' }, update: 'copier update --trust --defaults --conflict inline' },
260
- }
261
- }
262
-
263
- export function loadContract(root) {
264
- const source = readText(path.join(root, CONTRACT_FILE))
265
- const contract = source == null ? legacyContract(root) : readContract(source)
266
- if (!contract) return null
267
- const exceptions = readText(path.join(root, contract.exceptions))
268
- const entries = exceptions == null ? null : parseYaml(exceptions, contract.exceptions)?.exceptions
269
- return { ...contract, rules: toRules(contract.owned), exceptionList: Array.isArray(entries) ? entries.filter(isMapping) : [] }
270
- }
271
-
272
- export function normalizeRepoUrl(url) {
273
- return String(url ?? '')
274
- .trim()
275
- .replace(/^git\+/, '')
276
- .replace(/^gh:/, 'https://github.com/')
277
- .replace(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\//, 'https://$1/')
278
- .replace(/^git@([^:]+):/, 'https://$1/')
279
- .replace(/^(https?:\/\/)[^@/]+@/, '$1')
280
- .replace(/^http:\/\//, 'https://')
281
- .replace(/\/+$/, '')
282
- .replace(/\.git$/, '')
283
- .toLowerCase()
284
- }
285
-
286
- // The template repository carries its own contract, and its owned paths are its own to change.
287
- export function isTemplateSource(root, contract) {
288
- if (!contract?.source.url) return false
289
- let origin
290
- try {
291
- origin = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
292
- } catch {
293
- return false
294
- }
295
- return normalizeRepoUrl(origin) !== '' && normalizeRepoUrl(origin) === normalizeRepoUrl(contract.source.url)
296
- }
297
-
298
- const localDate = (now = new Date()) =>
299
- `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
300
-
301
- export function isOwned(contract, file, today = localDate()) {
302
- const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
303
- if (!matchesRules(contract.rules, relative)) return false
304
- return !contract.exceptionList.some(e =>
305
- isText(e.path) && /^\d{4}-\d{2}-\d{2}$/.test(String(e.expires ?? '')) && e.expires >= today && matchGlob(e.path, relative))
306
- }
307
-
308
- export function describeOwner(contract) {
309
- const { name, changes } = contract.source
310
- return {
311
- owner: `owned by ${name} (listed in ${contract.file})`,
312
- fix: `Ask for the change ${changes ? `at ${changes}` : `in ${name}`}, or add a dated exception to ${contract.exceptions}.`,
313
- }
314
- }
315
-
316
- export const nameKey = name => name.toLowerCase().replace(/[-_.\s]/g, '')
317
-
318
- export function declaredExports(declarations) {
319
- const names = [...declarations.matchAll(/^export\s+(?:declare\s+)?(?:default\s+)?(?:abstract\s+)?(?:function\*?|const|let|var|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm)].map(m => m[1])
320
- for (const list of declarations.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
321
- for (const item of list[1].split(',')) {
322
- const name = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()
323
- if (name && name !== 'default') names.push(name)
324
- }
325
- }
326
- return names
327
- }
328
-
329
- function packageNames(directory) {
330
- let manifest
331
- try {
332
- manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
333
- } catch {
334
- return []
335
- }
336
- const exportsMap = isMapping(manifest.exports) ? manifest.exports : {}
337
- const subpaths = Object.keys(exportsMap)
338
- .filter(key => key.startsWith('./') && !key.includes('*') && !/\.[a-z]+$/i.test(key))
339
- .map(key => path.posix.basename(key))
340
- const root = exportsMap['.'] ?? exportsMap
341
- const types = manifest.types ?? manifest.typings ?? (isText(root.types) ? root.types : null)
342
- const declarations = isText(types) ? readText(path.join(directory, types)) : null
343
- return [...subpaths, ...(declarations ? declaredExports(declarations) : [])]
344
- }
345
-
346
- // Packages resolve from the nearest node_modules, so a workspace app finds what its own package.json installs.
347
- export function reusableNames(root, contract, files = []) {
348
- const names = new Map()
349
- const add = (name, origin) => {
350
- const key = nameKey(name)
351
- if (key && !names.has(key)) names.set(key, { name, origin })
352
- }
353
- for (const name of contract.reuse.names) add(name, contract.file)
354
- const base = path.resolve(root)
355
- const directories = new Set([base])
356
- for (const file of files) {
357
- for (let dir = path.dirname(path.resolve(base, file)); dir.length > base.length && dir.startsWith(base); dir = path.dirname(dir)) directories.add(dir)
358
- }
359
- for (const pkg of contract.reuse.packages) {
360
- for (const dir of directories) {
361
- const installed = path.join(dir, 'node_modules', pkg)
362
- if (fs.existsSync(path.join(installed, 'package.json'))) for (const name of packageNames(installed)) add(name, pkg)
363
- }
364
- }
365
- return names
366
- }
1
+ import { execFileSync } from 'node:child_process'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ export const CONTRACT_FILE = '.platform/contract.yml'
6
+ const DEFAULT_EXCEPTIONS = '.platform/exceptions.yml'
7
+ const LEGACY_ANSWERS = '.copier-answers.yml'
8
+ const LEGACY_MANIFEST = '.platform/manifest'
9
+
10
+ export class ContractError extends Error {}
11
+
12
+ function bracket(segment, start) {
13
+ const end = segment.indexOf(']', start + 2)
14
+ if (end === -1) return null
15
+ let body = segment.slice(start + 1, end)
16
+ const negated = body[0] === '!' || body[0] === '^'
17
+ if (negated) body = body.slice(1)
18
+ const ranges = []
19
+ for (let i = 0; i < body.length; i++) {
20
+ const from = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
21
+ let to = from
22
+ if (body[i + 1] === '-' && i + 2 < body.length) {
23
+ i += 2
24
+ to = body[i] === '\\' && i + 1 < body.length ? body[++i] : body[i]
25
+ }
26
+ ranges.push([from, to])
27
+ }
28
+ return { token: { negated, ranges }, end }
29
+ }
30
+
31
+ const STAR = Symbol('*')
32
+
33
+ function segmentTokens(segment) {
34
+ const tokens = []
35
+ for (let i = 0; i < segment.length; i++) {
36
+ const c = segment[i]
37
+ const set = c === '[' ? bracket(segment, i) : null
38
+ if (c === '*') {
39
+ if (tokens.at(-1) !== STAR) tokens.push(STAR)
40
+ } else if (c === '?') tokens.push({ negated: true, ranges: [] })
41
+ else if (set) {
42
+ tokens.push(set.token)
43
+ i = set.end
44
+ } else tokens.push(c === '\\' && i + 1 < segment.length ? segment[++i] : c)
45
+ }
46
+ return tokens
47
+ }
48
+
49
+ const tokenMatches = (token, c) => typeof token === 'string'
50
+ ? token === c
51
+ : token.ranges.some(([from, to]) => from <= c && c <= to) !== token.negated
52
+
53
+ function matchSegment(tokens, text) {
54
+ let t = 0
55
+ let i = 0
56
+ let star = -1
57
+ let resume = 0
58
+ while (i < text.length) {
59
+ if (tokens[t] === STAR) {
60
+ star = t++
61
+ resume = i
62
+ } else if (t < tokens.length && tokenMatches(tokens[t], text[i])) {
63
+ t++
64
+ i++
65
+ } else if (star !== -1) {
66
+ t = star + 1
67
+ i = ++resume
68
+ } else return false
69
+ }
70
+ while (tokens[t] === STAR) t++
71
+ return t === tokens.length
72
+ }
73
+
74
+ // gitwildmatch over `/` segments; a RegExp built from contract text is a blocking finding in the apps' Semgrep scan.
75
+ export function compileGlob(glob) {
76
+ const directory = glob.endsWith('/')
77
+ const body = directory ? glob.slice(0, -1) : glob
78
+ const segments = (body.startsWith('/') ? body.slice(1) : body).split('/')
79
+ return { anchored: body.includes('/'), directory, segments: segments.map(s => (s === '**' ? s : segmentTokens(s))) }
80
+ }
81
+
82
+ function matchFrom(segments, s, parts, p, directory) {
83
+ if (s === segments.length) return parts.length - p >= (directory ? 1 : 0)
84
+ if (segments[s] !== '**') return p < parts.length && matchSegment(segments[s], parts[p]) && matchFrom(segments, s + 1, parts, p + 1, directory)
85
+ for (let next = s + 1 === segments.length ? p + 1 : p; next <= parts.length; next++) {
86
+ if (matchFrom(segments, s + 1, parts, next, directory)) return true
87
+ }
88
+ return false
89
+ }
90
+
91
+ export function matchGlob(compiled, file) {
92
+ const { anchored, directory, segments } = typeof compiled === 'string' ? compileGlob(compiled) : compiled
93
+ const parts = file.split('/')
94
+ for (let start = 0; start < (anchored ? 1 : parts.length); start++) {
95
+ if (matchFrom(segments, 0, parts, start, directory)) return true
96
+ }
97
+ return false
98
+ }
99
+
100
+ const toRules = globs => globs.map(glob => {
101
+ const negated = glob.startsWith('!')
102
+ return { negated, glob: compileGlob(negated ? glob.slice(1) : glob) }
103
+ })
104
+
105
+ function matchesRules(rules, file) {
106
+ let matched = false
107
+ for (const rule of rules) if (matchGlob(rule.glob, file)) matched = !rule.negated
108
+ return matched
109
+ }
110
+
111
+ function stripComment(line) {
112
+ let quote = null
113
+ for (let i = 0; i < line.length; i++) {
114
+ const c = line[i]
115
+ if (quote) quote = c === quote ? null : quote
116
+ else if ((c === '"' || c === "'") && (i === 0 || /[\s[,]/.test(line[i - 1]))) quote = c
117
+ else if (c === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i)
118
+ }
119
+ return line
120
+ }
121
+
122
+ function scalar(raw) {
123
+ const value = raw.trim()
124
+ const quoted = value.match(/^(["'])(.*)\1$/)
125
+ if (quoted) return quoted[2]
126
+ if (value.startsWith('[') && value.endsWith(']')) return value.slice(1, -1).split(',').map(scalar).filter(item => item !== '')
127
+ return /^-?\d+$/.test(value) ? Number(value) : value
128
+ }
129
+
130
+ const isItem = text => text === '-' || text.startsWith('- ')
131
+
132
+ // The subset of YAML a contract and its exceptions use: nested mappings, block and flow lists, scalars.
133
+ export function parseYaml(text, file) {
134
+ const lines = []
135
+ text.replace(/^\uFEFF/, '').split(/\r?\n/).forEach((raw, index) => {
136
+ const body = stripComment(raw).trimEnd()
137
+ if (body.trim()) lines.push({ indent: body.length - body.trimStart().length, text: body.trim(), number: index + 1 })
138
+ })
139
+ const fail = line => {
140
+ throw new ContractError(`${file}: line ${line.number} cannot be read: \`${line.text}\`.`)
141
+ }
142
+
143
+ function block(start, indent) {
144
+ const list = isItem(lines[start].text)
145
+ const value = list ? [] : {}
146
+ let i = start
147
+ while (i < lines.length && lines[i].indent === indent) {
148
+ const line = lines[i]
149
+ if (isItem(line.text) !== list) {
150
+ if (list) break
151
+ fail(line)
152
+ }
153
+ if (list) {
154
+ const rest = line.text.slice(1).trimStart()
155
+ if (/^[^"'[{][^:]*:(\s|$)/.test(rest)) {
156
+ lines[i] = { ...line, indent: indent + line.text.length - rest.length, text: rest }
157
+ } else {
158
+ i++
159
+ if (rest || !(i < lines.length && lines[i].indent > indent)) {
160
+ value.push(rest ? scalar(rest) : null)
161
+ continue
162
+ }
163
+ }
164
+ const [item, after] = block(i, lines[i].indent)
165
+ value.push(item)
166
+ i = after
167
+ continue
168
+ }
169
+ const pair = line.text.match(/^([^:]+?)\s*:(?:\s+(.*))?$/)
170
+ if (!pair) fail(line)
171
+ const key = scalar(pair[1])
172
+ i++
173
+ const next = lines[i]
174
+ if (pair[2]) value[key] = scalar(pair[2])
175
+ else if (next && (next.indent > indent || (next.indent === indent && isItem(next.text)))) [value[key], i] = block(i, next.indent)
176
+ else value[key] = null
177
+ }
178
+ if (i < lines.length && lines[i].indent > indent) fail(lines[i])
179
+ return [value, i]
180
+ }
181
+
182
+ if (!lines.length) return null
183
+ const [value, end] = block(0, lines[0].indent)
184
+ if (end < lines.length) fail(lines[end])
185
+ return value
186
+ }
187
+
188
+ const isText = value => typeof value === 'string' && value.trim() !== ''
189
+ const isMapping = value => value !== null && typeof value === 'object' && !Array.isArray(value)
190
+ const invalid = (file, field, problem) => new ContractError(`${file}: \`${field}\` ${problem}`)
191
+
192
+ function mapping(value, file, field, required = false) {
193
+ if (value == null && !required) return {}
194
+ if (!isMapping(value)) throw invalid(file, field, required ? 'is required and must be a mapping.' : 'must be a mapping.')
195
+ return value
196
+ }
197
+
198
+ function text(value, file, field, fallback) {
199
+ if (value == null && fallback !== undefined) return fallback
200
+ if (!isText(value)) throw invalid(file, field, fallback === undefined ? 'is required and must be a non-empty string.' : 'must be a non-empty string.')
201
+ return value
202
+ }
203
+
204
+ function textList(value, file, field) {
205
+ if (value == null) return []
206
+ const list = Array.isArray(value) ? value : [value]
207
+ if (!list.every(isText)) throw invalid(file, field, 'must be a list of non-empty strings.')
208
+ return list
209
+ }
210
+
211
+ export function readContract(source, file = CONTRACT_FILE) {
212
+ const raw = parseYaml(source, file)
213
+ if (!isMapping(raw)) throw new ContractError(`${file}: expected a mapping with \`version\`, \`source\` and \`owned\`.`)
214
+ if (raw.version == null) throw invalid(file, 'version', 'is required.')
215
+ if (String(raw.version) !== '1') throw invalid(file, 'version', `is ${raw.version}; this harness reads version 1.`)
216
+ const origin = mapping(raw.source, file, 'source', true)
217
+ const url = text(origin.url, file, 'source.url')
218
+ const owned = textList(raw.owned, file, 'owned')
219
+ if (!owned.length) throw invalid(file, 'owned', 'is required and must list at least one path.')
220
+ const reuse = mapping(raw.reuse, file, 'reuse')
221
+ const sync = mapping(raw.sync, file, 'sync')
222
+ const version = sync.version == null ? null : mapping(sync.version, file, 'sync.version')
223
+ return {
224
+ file,
225
+ source: { name: text(origin.name, file, 'source.name'), url, changes: text(origin.changes, file, 'source.changes', url) },
226
+ owned,
227
+ reuse: { docs: textList(reuse.docs, file, 'reuse.docs'), packages: textList(reuse.packages, file, 'reuse.packages'), names: textList(reuse.names, file, 'reuse.names') },
228
+ exceptions: text(raw.exceptions, file, 'exceptions', DEFAULT_EXCEPTIONS),
229
+ sync: {
230
+ marker: text(sync.marker, file, 'sync.marker', null),
231
+ version: version && { file: text(version.file, file, 'sync.version.file'), key: text(version.key, file, 'sync.version.key') },
232
+ update: text(sync.update, file, 'sync.update', null),
233
+ },
234
+ }
235
+ }
236
+
237
+ function readText(file) {
238
+ try {
239
+ return fs.readFileSync(file, 'utf8')
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+
245
+ // What harness 3.7.0 read before contract.yml existed: Copier answers plus a manifest of owned globs.
246
+ function legacyContract(root) {
247
+ const answers = readText(path.join(root, LEGACY_ANSWERS))
248
+ const manifest = answers == null ? null : readText(path.join(root, LEGACY_MANIFEST))
249
+ if (manifest == null) return null
250
+ const srcLine = answers.match(/^_src_path:(.*)$/m)
251
+ const url = srcLine ? scalar(stripComment(srcLine[1])) || null : null
252
+ const owned = manifest.split(/\r?\n/).map(line => line.replace(/(?<!\\)\s+$/, '')).filter(line => line && !line.startsWith('#'))
253
+ return {
254
+ file: LEGACY_MANIFEST,
255
+ source: { name: url ? url.replace(/\.git$/, '').split(/[\\/:]/).filter(Boolean).pop() : 'the upstream template', url, changes: url },
256
+ owned,
257
+ reuse: { docs: [], packages: [], names: [] },
258
+ exceptions: DEFAULT_EXCEPTIONS,
259
+ sync: { marker: LEGACY_ANSWERS, version: { file: LEGACY_ANSWERS, key: '_commit' }, update: 'copier update --trust --defaults --conflict inline' },
260
+ }
261
+ }
262
+
263
+ export function loadContract(root) {
264
+ const source = readText(path.join(root, CONTRACT_FILE))
265
+ const contract = source == null ? legacyContract(root) : readContract(source)
266
+ if (!contract) return null
267
+ const exceptions = readText(path.join(root, contract.exceptions))
268
+ const entries = exceptions == null ? null : parseYaml(exceptions, contract.exceptions)?.exceptions
269
+ return { ...contract, rules: toRules(contract.owned), exceptionList: Array.isArray(entries) ? entries.filter(isMapping) : [] }
270
+ }
271
+
272
+ export function normalizeRepoUrl(url) {
273
+ return String(url ?? '')
274
+ .trim()
275
+ .replace(/^git\+/, '')
276
+ .replace(/^gh:/, 'https://github.com/')
277
+ .replace(/^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\//, 'https://$1/')
278
+ .replace(/^git@([^:]+):/, 'https://$1/')
279
+ .replace(/^(https?:\/\/)[^@/]+@/, '$1')
280
+ .replace(/^http:\/\//, 'https://')
281
+ .replace(/\/+$/, '')
282
+ .replace(/\.git$/, '')
283
+ .toLowerCase()
284
+ }
285
+
286
+ // The template repository carries its own contract, and its owned paths are its own to change.
287
+ export function isTemplateSource(root, contract) {
288
+ if (!contract?.source.url) return false
289
+ let origin
290
+ try {
291
+ origin = execFileSync('git', ['config', '--get', 'remote.origin.url'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
292
+ } catch {
293
+ return false
294
+ }
295
+ return normalizeRepoUrl(origin) !== '' && normalizeRepoUrl(origin) === normalizeRepoUrl(contract.source.url)
296
+ }
297
+
298
+ const localDate = (now = new Date()) =>
299
+ `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
300
+
301
+ export function isOwned(contract, file, today = localDate()) {
302
+ const relative = file.replace(/\\/g, '/').replace(/^\.\//, '')
303
+ if (!matchesRules(contract.rules, relative)) return false
304
+ return !contract.exceptionList.some(e =>
305
+ isText(e.path) && /^\d{4}-\d{2}-\d{2}$/.test(String(e.expires ?? '')) && e.expires >= today && matchGlob(e.path, relative))
306
+ }
307
+
308
+ export function describeOwner(contract) {
309
+ const { name, changes } = contract.source
310
+ return {
311
+ owner: `owned by ${name} (listed in ${contract.file})`,
312
+ fix: `Ask for the change ${changes ? `at ${changes}` : `in ${name}`}, or add a dated exception to ${contract.exceptions}.`,
313
+ }
314
+ }
315
+
316
+ export const nameKey = name => name.toLowerCase().replace(/[-_.\s]/g, '')
317
+
318
+ export function declaredExports(declarations) {
319
+ const names = [...declarations.matchAll(/^export\s+(?:declare\s+)?(?:default\s+)?(?:abstract\s+)?(?:function\*?|const|let|var|class|interface|type|enum|namespace)\s+([A-Za-z_$][\w$]*)/gm)].map(m => m[1])
320
+ for (const list of declarations.matchAll(/^export\s+(?:type\s+)?\{([^}]*)\}/gm)) {
321
+ for (const item of list[1].split(',')) {
322
+ const name = item.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()
323
+ if (name && name !== 'default') names.push(name)
324
+ }
325
+ }
326
+ return names
327
+ }
328
+
329
+ function packageNames(directory) {
330
+ let manifest
331
+ try {
332
+ manifest = JSON.parse(fs.readFileSync(path.join(directory, 'package.json'), 'utf8'))
333
+ } catch {
334
+ return []
335
+ }
336
+ const exportsMap = isMapping(manifest.exports) ? manifest.exports : {}
337
+ const subpaths = Object.keys(exportsMap)
338
+ .filter(key => key.startsWith('./') && !key.includes('*') && !/\.[a-z]+$/i.test(key))
339
+ .map(key => path.posix.basename(key))
340
+ const root = exportsMap['.'] ?? exportsMap
341
+ const types = manifest.types ?? manifest.typings ?? (isText(root.types) ? root.types : null)
342
+ const declarations = isText(types) ? readText(path.join(directory, types)) : null
343
+ return [...subpaths, ...(declarations ? declaredExports(declarations) : [])]
344
+ }
345
+
346
+ // Packages resolve from the nearest node_modules, so a workspace app finds what its own package.json installs.
347
+ export function reusableNames(root, contract, files = []) {
348
+ const names = new Map()
349
+ const add = (name, origin) => {
350
+ const key = nameKey(name)
351
+ if (key && !names.has(key)) names.set(key, { name, origin })
352
+ }
353
+ for (const name of contract.reuse.names) add(name, contract.file)
354
+ const base = path.resolve(root)
355
+ const directories = new Set([base])
356
+ for (const file of files) {
357
+ for (let dir = path.dirname(path.resolve(base, file)); dir.length > base.length && dir.startsWith(base); dir = path.dirname(dir)) directories.add(dir)
358
+ }
359
+ for (const pkg of contract.reuse.packages) {
360
+ for (const dir of directories) {
361
+ const installed = path.join(dir, 'node_modules', pkg)
362
+ if (fs.existsSync(path.join(installed, 'package.json'))) for (const name of packageNames(installed)) add(name, pkg)
363
+ }
364
+ }
365
+ return names
366
+ }